diff --git a/changelog.d/3635.changed.md b/changelog.d/3635.changed.md new file mode 100644 index 0000000000..de7d76dca6 --- /dev/null +++ b/changelog.d/3635.changed.md @@ -0,0 +1 @@ +Refactor dashboard navlets to use HTMX for rendering and updates diff --git a/python/nav/web/navlets/__init__.py b/python/nav/web/navlets/__init__.py index 35329b69dc..b096d85b83 100644 --- a/python/nav/web/navlets/__init__.py +++ b/python/nav/web/navlets/__init__.py @@ -46,7 +46,9 @@ import logging import json +from datetime import datetime from operator import attrgetter +from typing import Union from django.conf import settings from django.http import HttpResponse, JsonResponse @@ -54,6 +56,7 @@ from django.urls import reverse from django.views.decorators.http import require_GET, require_POST from django.views.generic.base import TemplateView +from django_htmx.http import trigger_client_event from nav.models.profiles import AccountNavlet, AccountDashboard from nav.models.manage import Sensor @@ -116,21 +119,31 @@ def get_template_basename(self): """ raise NotImplementedError - def get_template_names(self): - """Get template name based on navlet mode""" - if self.mode == NAVLET_MODE_VIEW: + def get_template_names(self, override_mode=None): + """ + Get template name based on navlet mode. + + :param override_mode: Optional\; if provided, overrides the mode (VIEW or EDIT) + sent in the request. If None, uses self.mode. Used to enable the navlet to + return the correct template in error situations. + :type override_mode: str or None + :return: The template name for the specified mode. + """ + template_mode = override_mode or self.mode + if template_mode == NAVLET_MODE_VIEW: return 'navlets/%s_view.html' % self.get_template_basename() - elif self.mode == NAVLET_MODE_EDIT: + elif template_mode == NAVLET_MODE_EDIT: return 'navlets/%s_edit.html' % self.get_template_basename() else: return 'navlets/%s_view.html' % self.get_template_basename() - def get_context_data(self, **kwargs): + def get_context_data(self, override_mode=None, **kwargs): + template_mode = override_mode or self.mode context = super(Navlet, self).get_context_data(**kwargs) context['navlet'] = self - if self.mode == NAVLET_MODE_VIEW: + if template_mode == NAVLET_MODE_VIEW: context = self.get_context_data_view(context) - elif self.mode == NAVLET_MODE_EDIT: + elif template_mode == NAVLET_MODE_EDIT: context = self.get_context_data_edit(context) return context @@ -142,27 +155,49 @@ def get_context_data_edit(self, context): """Get context for the edit mode""" return context - def post(self, _request, **kwargs): + def post(self, request, *_, **kwargs): """Save preferences Make sure you're not overriding stuff with the form """ - form = kwargs.get('form') + form = kwargs.pop('form') if not form: return HttpResponse('No form supplied', status=400) if form.is_valid(): self.account_navlet.preferences.update(form.cleaned_data) self.account_navlet.save() - return HttpResponse() + return self.get(request=request) else: - return JsonResponse(form.errors, status=400) + return self.handle_error_response(request, form=form, **kwargs) + + def handle_error_response(self, request, form, **kwargs): + """Render error response for invalid form submissions""" + context = self.get_context_data( + override_mode=NAVLET_MODE_EDIT, form=form, **kwargs + ) + return render( + request, self.get_template_names(override_mode=NAVLET_MODE_EDIT), context + ) @classmethod def get_class(cls): """This string is used to identify the Widget""" return "%s.%s" % (cls.__module__, cls.__name__) + @staticmethod + def add_bust_param_to_url(url: str) -> str: + """ + Add a cache-busting parameter to a URL + + Used to ensure that images are not cached by the browser + + :param url: The URL to add the parameter to + :return: The URL with the cache-busting parameter added + """ + timestamp = int(datetime.now().timestamp()) + return f'{url}&bust={timestamp}' + def list_navlets(): """All Navlets that should be listed to the user""" @@ -253,6 +288,10 @@ def dispatcher(request, navlet_id): _logger.error( '%s tried to fetch widget with id %s: %s', current_account, navlet_id, error ) + if request.htmx: + return _handle_htmx_error_response( + request, None, 'This widget does not exist' + ) return HttpResponse(status=404) dashboard = account_navlet.dashboard @@ -266,7 +305,12 @@ def dispatcher(request, navlet_id): navlet_id, owner, ) - return HttpResponse(status=403) + if request.htmx: + return _handle_htmx_error_response( + request, account_navlet, 'Not authorized to view this widget' + ) + else: + return HttpResponse(status=403) cls = get_navlet_from_name(account_navlet.navlet) if not cls: @@ -282,18 +326,50 @@ def dispatcher(request, navlet_id): return view(request) +def _handle_htmx_error_response( + request, navlet: Union[AccountNavlet, None], error_message: str +): + """Render error response for htmx dispatcher requests""" + if navlet: + cls = get_navlet_from_name(navlet.navlet) + navlet = cls(request=request) + + return render( + request, + 'navlets/base.html', + { + 'navlet': navlet, + 'error_message': error_message, + }, + ) + + +@require_POST def add_user_navlet(request, dashboard_id=None): """Add a navlet subscription to this user""" - if request.method == 'POST' and 'navlet' in request.POST: - account = get_account(request) - dashboard = find_dashboard(account, dashboard_id=dashboard_id) + navlet_class = request.POST.get('navlet') + if not navlet_class: + return HttpResponse(status=400) - if can_modify_navlet(account, request): - navlet_class = request.POST.get('navlet') - navlet = add_navlet(account, navlet_class, dashboard=dashboard) - return JsonResponse(create_navlet_object(navlet)) + account = get_account(request) + dashboard = find_dashboard(account, dashboard_id=dashboard_id) - return HttpResponse(status=400) + if not can_modify_navlet(account, request): + return HttpResponse(status=403) + + navlet = add_navlet(account, navlet_class, dashboard=dashboard) + navlet_object = create_navlet_object(navlet) + response = render( + request, + 'navlets/_add_navlet_response.html', + context={'navlet': navlet_object}, + ) + return trigger_client_event( + response, + name="nav.navlet.added", + params={"navlet_id": navlet.id}, + after='settle', + ) def add_navlet_modal(request, dashboard_id): @@ -398,8 +474,23 @@ def remove_user_navlet(request): try: account_navlet = AccountNavlet.objects.get(pk=navlet_id, account=account) + dashboard = account_navlet.dashboard account_navlet.delete() - return resolve_modal(request, modal_id=modal_id) + + navlet_count = AccountNavlet.objects.filter(dashboard=dashboard).count() + response = resolve_modal( + request, + 'navlets/_remove_navlet_response.html', + context={'navlet_count': navlet_count}, + modal_id=modal_id, + ) + + return trigger_client_event( + response, + name="nav.navlet.removed", + params={"navlet_id": navlet_id}, + after='settle', + ) except AccountNavlet.DoesNotExist: return render_modal_alert( request, 'This widget no longer exists. Try refreshing the page.', modal_id @@ -438,28 +529,6 @@ def update_navlet(account, key, value, column): navlet.save() -def render_base_template(request): - """Render only base template with navlet info - - This is used to render only buttons and title of the navlet, - and is used when an error occured rendering the whole navlet. See - doc in navlet_controller.js for more info - - """ - try: - navlet_id = int(request.GET.get('id')) - except (ValueError, TypeError): - # We're fucked - return HttpResponse(status=400) - else: - account = get_account(request) - accountnavlet = get_object_or_404(AccountNavlet, account=account, pk=navlet_id) - _logger.error(accountnavlet) - cls = get_navlet_from_name(accountnavlet.navlet) - navlet = cls(request=request) - return render(request, 'navlets/base.html', {'navlet': navlet}) - - def add_user_navlet_graph(request): """Add a Graph Widget with url set to user dashboard""" if request.method == 'POST': diff --git a/python/nav/web/navlets/feedreader.py b/python/nav/web/navlets/feedreader.py index e5dc8ec7e2..8b1e0ecd49 100644 --- a/python/nav/web/navlets/feedreader.py +++ b/python/nav/web/navlets/feedreader.py @@ -16,7 +16,6 @@ """Feed reader widget""" import feedparser -from django.http import HttpResponse from nav.web.auth.utils import get_account from nav.models.profiles import AccountNavlet from nav.web.navlets import Navlet, NAVLET_MODE_VIEW @@ -74,4 +73,4 @@ def post(self, request): account_navlet.preferences['maxposts'] = maxposts account_navlet.save() - return HttpResponse() + return self.get(request) diff --git a/python/nav/web/navlets/graph.py b/python/nav/web/navlets/graph.py index e73748e95e..9de882daf8 100644 --- a/python/nav/web/navlets/graph.py +++ b/python/nav/web/navlets/graph.py @@ -56,7 +56,9 @@ def get_context_data_view(self, context): self.title = self.get_title() show_controls = self.preferences.get('show_controls') context['hide_buttons'] = 'false' if show_controls else 'true' - context['graph_url'] = self.preferences.get('url') + url = self.preferences.get('url') + if url: + context['graph_url'] = self.add_bust_param_to_url(url) return context def get_context_data_edit(self, context): diff --git a/python/nav/web/navlets/report.py b/python/nav/web/navlets/report.py index eddddc2a1f..7af1da78e7 100644 --- a/python/nav/web/navlets/report.py +++ b/python/nav/web/navlets/report.py @@ -16,7 +16,7 @@ # """Report widget""" -from django.http import HttpResponse, JsonResponse, QueryDict +from django.http import HttpResponse, QueryDict from nav.models.profiles import AccountNavlet from nav.web.auth.utils import get_account from nav.web.report.views import CONFIG_DIR, make_report @@ -76,7 +76,7 @@ def post(self, request): navlet.preferences['report_id'] = request.POST.get('report_id') navlet.preferences['query_string'] = request.POST.get('query_string') navlet.save() - return JsonResponse(navlet.preferences) + return self.get(request) def get_report_names(): diff --git a/python/nav/web/navlets/status2.py b/python/nav/web/navlets/status2.py index f32adf064a..7e2c0b2fb7 100644 --- a/python/nav/web/navlets/status2.py +++ b/python/nav/web/navlets/status2.py @@ -18,7 +18,7 @@ from datetime import datetime from operator import itemgetter -from django.http import QueryDict, JsonResponse +from django.http import QueryDict from django.test.client import RequestFactory from django.utils.datastructures import MultiValueDictKeyError from django.utils.dateparse import parse_datetime @@ -113,7 +113,7 @@ def format_time(timestampstring): date_format = '%H:%M:%S' return timestamp.strftime(date_format) - def post(self, request): + def post(self, request, **kwargs): """Save navlet options on post""" navlet = self.account_navlet form = StatusWidgetForm(request.POST) @@ -126,6 +126,6 @@ def post(self, request): except (TypeError, ValueError, MultiValueDictKeyError): pass navlet.save() - return JsonResponse(self.preferences) + return self.get(request) else: - return JsonResponse(form.errors, status=400) + return self.handle_error_response(request, form, **kwargs) diff --git a/python/nav/web/navlets/urls.py b/python/nav/web/navlets/urls.py index 330b66656d..6a838c157d 100644 --- a/python/nav/web/navlets/urls.py +++ b/python/nav/web/navlets/urls.py @@ -26,7 +26,6 @@ remove_user_navlet_modal, dispatcher, save_navlet_order, - render_base_template, set_navlet_preferences, add_user_navlet_sensor, ) @@ -65,9 +64,6 @@ ), re_path(r'^get-user-navlet/(?P\d+)', dispatcher, name='get-user-navlet'), re_path(r'^save-navlet-order', save_navlet_order, name='save-navlet-order'), - re_path( - r'^navlet-base-template/', render_base_template, name='navlet-base-template' - ), re_path( r'^set-navlet-preferences', set_navlet_preferences, diff --git a/python/nav/web/navlets/vlangraph.py b/python/nav/web/navlets/vlangraph.py index d44fcc23ef..262fba967c 100644 --- a/python/nav/web/navlets/vlangraph.py +++ b/python/nav/web/navlets/vlangraph.py @@ -46,7 +46,8 @@ def get_context_data(self, **kwargs): vlanid = self.preferences['vlanid'] if 'vlanid' in self.preferences else None if self.mode == NAVLET_MODE_VIEW and vlanid: url = get_vlan_graph_url(vlanid) - context['graph_url'] = url + if url: + context['graph_url'] = self.add_bust_param_to_url(url) elif self.mode == NAVLET_MODE_EDIT: context['vlans'] = Vlan.objects.filter(vlan__isnull=False).order_by('vlan') context['vlanid'] = vlanid @@ -71,4 +72,6 @@ def post(self, request): account_navlet.preferences['vlanid'] = vlanid account_navlet.save() - return HttpResponse() + self.preferences = account_navlet.preferences + + return self.get(request) diff --git a/python/nav/web/sass/nav/navlets.scss b/python/nav/web/sass/nav/navlets.scss index efec71dcee..e9773231d7 100644 --- a/python/nav/web/sass/nav/navlets.scss +++ b/python/nav/web/sass/nav/navlets.scss @@ -27,7 +27,7 @@ $fullscreen-color: #EEE; margin-bottom: 1em; padding: 0.5em 1em 1em 1.2em; position: relative; - min-height: 100px; + min-height: 200px; transition: box-shadow .5s; &.outline { border: 1px dashed black; @@ -36,7 +36,7 @@ $fullscreen-color: #EEE; box-shadow: #666 0px 5px 10px 7px; z-index: 2; } - &.colorblock-navlet { + &.colorblock-navlet, &[data-highlight="true"] { background-color: #1570a6; color: #fff; .subheader { diff --git a/python/nav/web/sass/nav/navlets_compact.scss b/python/nav/web/sass/nav/navlets_compact.scss index a866459cd5..bd8f7d3574 100644 --- a/python/nav/web/sass/nav/navlets_compact.scss +++ b/python/nav/web/sass/nav/navlets_compact.scss @@ -1,4 +1,4 @@ -#navlets.compact { +#navlets-htmx.compact { font-size: 90%; .navlet { margin-bottom: 0; diff --git a/python/nav/web/static/js/src/plugins/navlet_alert.js b/python/nav/web/static/js/src/plugins/navlet_alert.js new file mode 100644 index 0000000000..5f339034e1 --- /dev/null +++ b/python/nav/web/static/js/src/plugins/navlet_alert.js @@ -0,0 +1,90 @@ +define((require, exports, module) => { + + const DEFAULT_OPTIONS ={ + alertType: '', + dataUrl: '', + onState: null, + onMessage: 'No on message provided', + offMessage: 'No off message provided' + } + + function AlertController($navlet, options) { + this.$navlet = $navlet; + this.$alertBox = $navlet.find('.alert-box'); + this.$timestamp = $navlet.find('.alert-update-timestamp span'); + this.options = {...DEFAULT_OPTIONS, ...options}; + this.text = { + on: options.onMessage, + off: options.offMessage, + unknown: 'N/A' + }; + this.alertType = options.alertType; + this.isDestroyed = false; + + this.update(); + this.refreshHandler = this.update.bind(this); + this.cleanupHandler = this.cleanup.bind(this); + $navlet.on('refresh', this.refreshHandler); + $navlet.on('htmx:beforeSwap', this.cleanupHandler); + } + + AlertController.prototype.cleanup = function() { + if (this.isDestroyed) return; + this.$navlet.off('refresh', this.refreshHandler); + this.$navlet.off('htmx:beforeSwap', this.cleanupHandler); + this.isDestroyed = true; + } + + AlertController.prototype.feedBack = function(text, klass) { + this.$alertBox.attr('class', 'alert-box with-icon'); + if (klass) { + this.$alertBox.addClass(klass); + } + this.$alertBox.html(text); + } + + AlertController.prototype.update = function() { + if (this.isDestroyed) return; + const request = $.get(this.options.dataUrl); + + request.done((data) => { + if (!data || data.length === 0) { + this.feedBack('Got no data from Graphite - perhaps the metric name is wrong?'); + return; + } + + if (!data[0]?.datapoints) { + this.feedBack('Got invalid data from Graphite'); + return; + } + + const datapoints = data[0].datapoints.reverse(); + + this.$timestamp.text('N/A'); + + for (let i=0; i= 3) { + this.feedBack(this.text.unknown); + } + } + }); + + // Very basic error handling + request.fail((jqXhr, textStatus, errorThrown) => { + this.feedBack(['Error updating widget:', jqXhr.status, jqXhr.statusText].join(' ')); + }); + } + + module.exports = AlertController; +}); diff --git a/python/nav/web/static/js/src/plugins/navlet_controller.js b/python/nav/web/static/js/src/plugins/navlet_controller.js deleted file mode 100644 index 082c49cbf1..0000000000 --- a/python/nav/web/static/js/src/plugins/navlet_controller.js +++ /dev/null @@ -1,302 +0,0 @@ -define(['libs/urijs/URI', 'libs/spin.min'], function (URI, Spinner) { - - /* - * Controller for a specific Navlet - * - * container: A wrapper for all Navlets containing data-attributes useful for the navlet - * renderNode: In case of columns or other structures, this is the node the navlet should render in - * navlet: An object containing the id for the navlet and the url to display it - * - * The controller initiates a request to the url given in the navlet object, - * puts the result in its own node and add it to the renderNode. - * - * If the request fails for some reason, we need to make another request to - * fetch the base template with the buttons and title rendered. This is - * suboptimal, but only leads to overhead on errors. - * - */ - - var NavletController = function (container, renderNode, navlet, forceFirst) { - this.container = container; // Navlet container - this.renderNode = renderNode; // The column this navlet should render in - this.navlet = navlet; // Object containing navlet information - this.spinner = new Spinner({zIndex: 10}); // Spinner showing on load - this.forceFirst = typeof forceFirst === 'undefined' ? false : true; - this.node = this.createNode(); // The complete node for this navlet - this.removeUrl = this.container.attr('data-remove-navlet'); // Url to use to remove a navlet from this user - this.baseTemplateUrl = this.container.attr('data-base-template-url'); // Url to use to fetch base template for this navlet - - this.renderNavlet('VIEW'); - }; - - NavletController.prototype = { - createNode: function () { - /* Creates the node that the navlet will loaded into */ - var self = this; - var $div = $('
'); - $div.attr({ - 'data-id': this.navlet.id, - 'class': 'navlet' - }); - - $div.addClass(this.navlet.navlet_class); - - if (this.navlet.highlight) { - $div.addClass('colorblock-navlet'); - } - - if (this.forceFirst) { - this.renderNode.prepend($div); - $div.on('mouseover', function() { - $div.removeClass('mark-new'); - self.forceFirst = false; - }); - } else { - this.renderNode.append($div); - } - return $div; - }, - renderNavlet: function (mode) { - /* Renders the navlet and inserts the html */ - var that = this; - - if (mode === undefined) { - mode = 'VIEW'; - } - - var request = $.ajax({ - url: this.navlet.url, - data: {'mode': mode}, - beforeSend: function () { - that.spinner.spin(that.node.get(0)); - } - }); - request.done(function (html) { - that.handleSuccessfulRequest(html, mode); - }); - request.fail(function (jqxhr, textStatus, errorThrown) { - that.handleErrorRequest(jqxhr); - }); - request.always(function () { - that.spinner.stop(); - }); - - }, - handleSuccessfulRequest: function (html, mode) { - this.node.html(html); - - /* - Process the new navlet with htmx to initialize any hx-* attributes - Required because content loaded via jQuery AJAX is not automatically processed by htmx. - */ - htmx.process(this.node.get(0)); - - if (this.forceFirst) { - this.node.addClass('mark-new'); - } - this.applyListeners(); - this.addReloader(mode); - }, - handleErrorRequest: function (jqxhr) { - /* - * Fetch base template for this navlet, display that and error - */ - var that = this, - request = $.get(this.baseTemplateUrl, {id: this.navlet.id}); - request.done(function (html) { - that.node.html(html); - that.applyListeners(); - if (jqxhr.status === 401 || jqxhr.status === 403) { - that.displayError('Not allowed'); - } else { - that.displayError('Could not load widget'); - } - }); - request.fail(function () { - that.displayError('Could not load widget'); - }); - }, - addReloader: function (mode) { - /* - * Reload periodically based on preferences - * Remember to stop refreshing on edit - */ - var that = this, - preferences = this.navlet.preferences; - - if (mode === 'VIEW' && preferences && preferences.refresh_interval) { - /* If this is a reload of image only */ - if (this.navlet.image_reload) { - this.refresh = setInterval(function () { - // Find image each time because of async loading - var image = that.node.find('img[data-image-reload], [data-image-reload] img'); - if (image.length) { - // Add bust parameter to url to prevent caching - var uri = new URI(image.get(0)).setSearch('bust', Math.random()); - image.attr('src', uri.href()); - } - }, preferences.refresh_interval); - } else if (this.navlet.ajax_reload) { - this.refresh = setInterval(function () { - that.node.trigger('refresh', [that.node]); - }, preferences.refresh_interval); - } else { - this.refresh = setTimeout(function () { - that.renderNavlet.call(that); - }, preferences.refresh_interval); - } - } else if (mode === 'EDIT' && this.refresh) { - clearTimeout(this.refresh); - clearInterval(this.refresh); - } - }, - getModeSwitch: function () { - /* Return edit-button */ - return this.node.find('.navlet-mode-switch'); - }, - getRenderMode: function () { - /* Return mode based on edit-button. If it does not exist return VIEW */ - var modeSwitch = this.getModeSwitch(), - mode = 'VIEW'; - if (modeSwitch.length) { - mode = modeSwitch.attr('data-mode'); - } - return mode; - }, - applyListeners: function () { - /* Applies listeners to the relevant elements */ - this.applyModeListener(); -// this.applyReloadListener(); - this.applySubmitListener(); - if (this.navlet.is_title_editable && this.node.find('.subheader[data-set-title]').get(0)) { - this.applyTitleListener(); - } - this.applyOnRenderedListener(); - }, - applyModeListener: function () { - /* Renders the navlet in the correct mode (view or edit) when clicking the switch button */ - var that = this, - modeSwitch = this.getModeSwitch(); - - if (modeSwitch.length > 0) { - var mode = this.getRenderMode() === 'VIEW' ? 'EDIT' : 'VIEW'; - - modeSwitch.click(function () { - that.node.trigger('render', [mode]); - that.renderNavlet(mode); - }); - } - }, - applyReloadListener: function () { - var that = this, - reloadButton = this.node.find('.navlet-reload-button'); - reloadButton.on('click', function () { - that.renderNavlet(); - }); - }, - applySubmitListener: function () { - /* - * Make sure a form in edit-mode is submitted by ajax, so that the - * page does not reload. - * - * Preferences may be returned, handle and store them - */ - if (this.getRenderMode() === 'EDIT') { - var that = this, - form = this.node.find('form'); - - form.submit(function (event) { - event.preventDefault(); - var request = $.post(form.attr('action'), $(this).serialize()); - request.done(function (data) { - // Update preferences if they are sent back from controller - if (data) { - that.navlet.preferences = JSON.parse(data); - } - that.renderNavlet('VIEW'); - }); - request.fail(function (jqxhr) { - try { - // Result may be json, try to parse it (specific for form errors) - var json = JSON.parse(jqxhr.responseText); - var $ul = $('
    '); - for (var field in json) { - var errors = $('
  • ').html(field + ': ' + json[field].map(function(x) { - return x.message ? x.message : x; - }).join(', ')); - $ul.append(errors); - } - that.displayError($ul); - } catch (e) { - that.displayError('Could not save changes: ' + jqxhr.responseText); - } - }); - }); - - this.node.find('.cancel-button').on('click', function() { - that.getModeSwitch().click(); - }); - } - }, - applyTitleListener: function () { - /* Feel free to refactor this mess */ - var self = this; - this.node.find('.subheader').click(function () { - var $header = $(this), - $container = $($header.parents('.title-container').get(0)), - $input = $('').val($header.find('.navlet-title').text()); - - $header.hide(); - $container.append($input); - $input.on('keydown', function (event) { - if (event.which === 13) { - const csrfToken = $('#navlets-action-form input[name="csrfmiddlewaretoken"]').val(); - const request = $.post({ - url: $header.attr('data-set-title'), - type: 'POST', - data: { - 'id': self.navlet.id, - 'preferences': JSON.stringify({ - 'title': $input.val() - }) - }, - headers: { - 'X-CSRFToken': csrfToken - } - }); - request.done(function () { - $header.find('.navlet-title').text($input.val()); - }); - request.error(function () { - alert("The Oompa Loompas didn't want to change the title (an error occured) - sorry!"); - }); - request.always(function () { - $input.remove(); - $header.show(); - }); - } - }); - }); - }, - applyOnRenderedListener: function () { - this.container.trigger('navlet-rendered', [this.node]); - }, - displayError: function (errorMessage) { - this.getOrCreateErrorElement().html(errorMessage); - }, - getOrCreateErrorElement: function () { - /* If error element is already created, return existing element */ - var $element = this.node.find('.alert-box.alert'); - - if (!$element.length) { - $element = $(''); - $element.appendTo(this.node); - } - - return $element; - } - - }; - - return NavletController; -}); diff --git a/python/nav/web/static/js/src/plugins/navlet_handlers.js b/python/nav/web/static/js/src/plugins/navlet_handlers.js new file mode 100644 index 0000000000..41d7c5279d --- /dev/null +++ b/python/nav/web/static/js/src/plugins/navlet_handlers.js @@ -0,0 +1,100 @@ +/** + * Navlet Handlers + * + * Provides widget-specific initialization handlers for navlets after HTMX content swaps. + * Manages interactive components within dashboard widgets and Select2 dropdowns. + */ +define([ + 'plugins/room_mapper', + 'plugins/sensors_controller', + 'src/getting_started_wizard' +], function(RoomMapper, SensorsController, GettingStartedWizard) { + + const NAVLET_TYPES = { + ROOM_MAP: 'RoomMapNavlet', + SENSOR: 'SensorWidget', + GETTING_STARTED: 'GettingStartedWidget', + WATCHDOG: 'WatchDogWidget' + } + const NAVLET_SET = new Set(Object.values(NAVLET_TYPES)); + // If the element has the `select2-offscreen` class, it means select2 was previously initialized. + // TODO: Update class detection when upgrading to select2 v4. + // See: https://select2.org/programmatic-control/methods#checking-if-the-plugin-is-initialized + const SELECT2_INITIALIZED_CLASS = 'select2-initialized'; + const SELECT2_REINIT_DELAY_MS = 100; + + const handlers = { + [NAVLET_TYPES.ROOM_MAP]: function ($node) { + const room_map = $node.find('#room_map'); + if (!room_map.length) return; + const map_wrapper = $node.find('.mapwrapper'); + map_wrapper.show(); + // Constructor is used for side effects. NOSONAR + new RoomMapper(room_map.get(0)); + }, + + [NAVLET_TYPES.SENSOR]: function ($node) { + const sensors = $node.find('.room-sensor'); + if (sensors.length) { + // Constructor is used for side effects. NOSONAR + new SensorsController(sensors); + } + }, + + [NAVLET_TYPES.GETTING_STARTED]: function ($node) { + $node.on('click', '#getting-started-wizard', function () { + GettingStartedWizard.start(); + }); + }, + + [NAVLET_TYPES.WATCHDOG]: function ($node) { + $node.on('click', '.watchdog-tests .label.alert', function (event) { + $(event.target).closest('li').find('ul').toggle(); + }); + } + }; + + function getNavletType($node) { + const classes = $node.attr('class').split(' ').filter(cls => NAVLET_SET.has(cls)); + return classes.length ? classes[0] : null; + } + + function handleNavletType($node) { + const navletType = getNavletType($node); + if (!navletType) return; + + const handler = handlers[navletType]; + if (!handler) return; + try { + handler($node); + } catch (error) { + console.error(`Failed to initialize ${navletType}:`, error); + } + } + + function handleSelect2Initialization($swappedNode) { + const $selectElements = $swappedNode.find('select'); + + if ($selectElements.length > 0) { + $selectElements.each((_, element) => { + if ($(element).hasClass(SELECT2_INITIALIZED_CLASS)) { + // Re-initialize after a short delay to allow destroy to complete + // Timeout value selected based on manual testing + setTimeout(() => { + $(element).select2(); + }, SELECT2_REINIT_DELAY_MS); + } else { + $(element).select2(); + } + }); + } + } + + return { + handle: function (swappedNode) { + const $swappedNode = $(swappedNode); + handleNavletType($swappedNode); + handleSelect2Initialization($swappedNode); + }, + }; +}); diff --git a/python/nav/web/static/js/src/plugins/navlet_pdu.js b/python/nav/web/static/js/src/plugins/navlet_pdu.js index 875da3e727..c2074e2213 100644 --- a/python/nav/web/static/js/src/plugins/navlet_pdu.js +++ b/python/nav/web/static/js/src/plugins/navlet_pdu.js @@ -15,17 +15,20 @@ define(function(require, exports, module) { this.limits = getLimits(this.$navlet.find('.pdu-load-status').data('limits')).sort().reverse(); this.config = getConfig(this.limits.length); this.parameters = getParameters(metrics); + this.isDestroyed = false; this.update(); - $navlet.on('refresh', this.update.bind(this)); // navlet controller determines when to update - $navlet.on('render', function(event, renderType){ - /* We need to unregister eventlistener, as it will not be removed - when going into edit-mode, and thus we will have one for each time - you have edited the widget. */ - if (renderType === 'EDIT') { - $navlet.off('refresh'); - } - }); + this.refreshHandler = this.update.bind(this); + this.cleanupHandler = this.cleanup.bind(this); + $navlet.on('refresh', this.refreshHandler); + $navlet.on('htmx:beforeSwap', this.cleanupHandler); + } + + PduController.prototype.cleanup = function() { + if (this.isDestroyed) return; + this.$navlet.off('refresh'); + this.$navlet.off('htmx:beforeSwap'); + this.isDestroyed = true; } PduController.prototype.update = function() { diff --git a/python/nav/web/static/js/src/plugins/navlet_ups.js b/python/nav/web/static/js/src/plugins/navlet_ups.js index c13a2f0524..9ad4d5b4d5 100644 --- a/python/nav/web/static/js/src/plugins/navlet_ups.js +++ b/python/nav/web/static/js/src/plugins/navlet_ups.js @@ -16,18 +16,21 @@ define(function(require, exports, module) { }); this.metricList = _.keys(this.sensors); + this.isDestroyed = false; // Run collection and listen to events this.update(); - $navlet.on('refresh', this.update.bind(this)); - this.$navlet.on('render', function(event, renderType){ - /* We need to unregister refresh listener, as it will not be removed - when going into edit-mode, and thus we will have one for each time - you have edited the widget. */ - if (renderType === 'EDIT') { - self.$navlet.off('refresh'); - } - }); + this.refreshHandler = this.update.bind(this); + this.cleanupHandler = this.cleanup.bind(this); + $navlet.on('refresh', this.refreshHandler); + $navlet.on('htmx:beforeSwap', this.cleanupHandler); + } + + Poller.prototype.cleanup = function() { + if (this.isDestroyed) return; + this.$navlet.off('refresh'); + this.$navlet.off('htmx:beforeSwap'); + this.isDestroyed = true; } Poller.prototype.update = function() { diff --git a/python/nav/web/static/js/src/plugins/navlets_controller.js b/python/nav/web/static/js/src/plugins/navlets_controller.js deleted file mode 100644 index 877242201c..0000000000 --- a/python/nav/web/static/js/src/plugins/navlets_controller.js +++ /dev/null @@ -1,176 +0,0 @@ -define(['plugins/navlet_controller'], function (NavletController) { - - /** - * Controller for loading and laying out the navlets, and adding buttons for - * manipulating the navlets - */ - - // What class to use for the different number of columns - var columnsMapper = { - '2': 'medium-6', - '3': 'medium-4', - '4': 'medium-3' - }; - - function NavletsController(node, columns) { - this.container = node; - this.numColumns = columns || 2; - this.columns = this.createLayout(this.container, this.numColumns); - - this.fetch_navlets_url = this.container.attr('data-list-navlets'); - this.save_ordering_url = this.container.attr('data-save-order-url'); - - this.navletSelector = '.navlet'; - this.sorterSelector = '.navletColumn'; - - this.addContentListener(); - - this.fetchNavlets(); - this.addAddNavletListener(); - } - - NavletsController.prototype = { - createLayout: function (container, numColumns) { - var $row = $('
    ').appendTo(container), - classes = "column navletColumn " + columnsMapper[numColumns], - columns = []; - - if (this.container.hasClass('compact')) { - $row.addClass('collapse'); - } - - for(var i = 0; i < numColumns; i++) { - columns.push($('
    ').addClass(classes).appendTo($row)); - } - return columns; - }, - - /** Displays an infobox when there are no widgets on a dashboard. */ - addContentListener: function() { - var self = this; - var message = $('
    No widgets added to this dashboard
    '); - var messageContainer = this.container.find('.navletColumn:first'); - this.container.on('nav.navlets.fetched', function(event, meta) { - if (meta.numNavlets === 0) { - messageContainer.append(message); - } - }); - this.container.on('nav.navlet.added', function() { - message.detach(); - }); - this.container.on('nav.navlet.removed', function() { - if (self.container.find('.navlet').length === 0) { - messageContainer.append(message); - } - }); - }, - fetchNavlets: function () { - var that = this, - request_config = { - cache: false, // Need to disable caching because of IE - dataType: 'json', - url: this.fetch_navlets_url - }, - request = $.ajax(request_config); - - request.done(function (data) { - var i, l; - for (i = 0, l = data.items.length; i < l; i++) { - that.addNavlet(data.items[i]); - } - that.addNavletOrdering(); - that.container.trigger('nav.navlets.fetched', {numNavlets: data.items.length}); - }); - }, - addAddNavletListener: function () { - const that = this; - $(document).on('submit', '.add-user-navlet', function (event) { - event.preventDefault(); - const $form = $(this); - const request = $.post($form.attr('action'), $form.serialize(), 'json'); - - request.done(function (data) { - that.addNavlet(data, true); - that.saveOrder(that.findOrder()); - $('#navlet-list').remove(); - }); - - request.fail(function () { - alert('Failed to add widget'); - }); - }); - }, - - /** - * Spawn a new widget on the existing dashboard. - * - * Triggers the event 'nav.navlet.added' on the widgets container. - * - * @param {object} data - metadata about the widget - * @param {boolean} forceFirst - Force this widget to be placed in the top left corner. - */ - addNavlet: function (data, forceFirst) { - var column = data.column > this.numColumns ? this.numColumns : data.column; - column = column < 1 ? 1 : column; - new NavletController(this.container, this.columns[column - 1], data, forceFirst); - this.container.trigger('nav.navlet.added'); - }, - addNavletOrdering: function () { - var that = this; - - this.container.find(this.sorterSelector).sortable({ - connectWith: '.navletColumn', - forcePlaceholderSize: true, - handle: '.navlet-drag-button', - placeholder: 'highlight', - tolerance: 'pointer', - start: function () { - that.getNavlets().addClass('outline'); - }, - stop: function () { - that.getNavlets().removeClass('outline'); - }, - update: function () { - that.saveOrder(that.findOrder()); - } - }); - - }, - findOrder: function () { - var orderings = []; - for(var i = 0; i < this.columns.length; i++) { - var columnNavlets = {}; - this.getNavlets(this.columns[i]).each(function (index, navlet) { - columnNavlets[$(navlet).attr('data-id')] = index; - }); - orderings.push(columnNavlets); - } - return orderings; - }, - saveOrder: function (ordering) { - // Get csrf token from #navlets-action-form - const csrfToken = $('#navlets-action-form input[name="csrfmiddlewaretoken"]').val(); - $.ajax({ - url: this.save_ordering_url, - type: 'POST', - data: JSON.stringify(ordering), - contentType: 'application/json', - headers: { - 'X-CSRFToken': csrfToken - } - }).fail(function() { - console.error('Failed to save widget order'); - }); - }, - getNavlets: function (column) { - if (column) { - return column.find(this.navletSelector); - } else { - return this.container.find(this.navletSelector); - } - }, - }; - - return NavletsController; - -}); diff --git a/python/nav/web/static/js/src/plugins/navlets_htmx_controller.js b/python/nav/web/static/js/src/plugins/navlets_htmx_controller.js new file mode 100644 index 0000000000..54d245aa74 --- /dev/null +++ b/python/nav/web/static/js/src/plugins/navlets_htmx_controller.js @@ -0,0 +1,153 @@ +/** + * Navlets HTMX Controller + * + * Manages the interactive dashboard widget system with drag-and-drop functionality, + * HTMX integration, and dynamic widget lifecycle management. + * + * Features: + * - Sortable widget columns with drag-and-drop reordering + * - HTMX event handling for dynamic content updates + * - Automatic widget initialization and cleanup + * - Order persistence to backend via AJAX + * - Visual feedback for user interactions + */ +define([ + 'plugins/navlet_handlers' +], function (NavletHandlers) { + + const NAVLETS_CONTAINER_ID = 'navlets-htmx'; + + const CSS_CLASSES = { + NAVLET: 'navlet', + OUTLINE: 'outline', + MARK_NEW: 'mark-new', + } + const SELECTORS = { + NAVLET: '.' + CSS_CLASSES.NAVLET, + SORTER: '.navletColumn', + DRAG_HANDLE: '.navlet-drag-button', + CSRF_TOKEN: '#navlets-action-form input[name="csrfmiddlewaretoken"]' + } + + function NavletsHtmxController() { + this.container = $('#' + NAVLETS_CONTAINER_ID); + this.save_ordering_url = this.container.attr('data-save-order-url'); + + this.addListeners(); + } + + NavletsHtmxController.prototype = { + addListeners: function () { + this.initSortable(); + }, + + initSortable: function () { + const $sorterSelectors = this.container.find(SELECTORS.SORTER); + if ($sorterSelectors.length === 0) { + return; + } + $sorterSelectors.sortable({ + connectWith: SELECTORS.SORTER, + forcePlaceholderSize: true, + handle: SELECTORS.DRAG_HANDLE, + placeholder: 'highlight', + tolerance: 'pointer', + start: () => this.toggleNavletOutline(true), + stop: () => this.toggleNavletOutline(false), + update: () => this.updateOrder() + }); + }, + + toggleNavletOutline: function (show) { + this.getNavlets().toggleClass(CSS_CLASSES.OUTLINE, show); + }, + + updateOrder: function () { + this.saveOrder(this.findOrder()); + }, + + findOrder: function () { + return this.container.find(SELECTORS.SORTER).toArray().map((column) => { + const columnNavlets = {}; + this.getNavlets(column).each((idx, navlet) => { + const navletId = $(navlet).attr('data-id'); + if (navletId) { + columnNavlets[navletId] = idx; + } + }); + return columnNavlets; + }); + }, + + saveOrder: function (ordering) { + // Get csrf token from #navlets-action-form + const csrfToken = $(SELECTORS.CSRF_TOKEN).val(); + $.ajax({ + url: this.save_ordering_url, + type: 'POST', + data: JSON.stringify(ordering), + contentType: 'application/json', + headers: { + 'X-CSRFToken': csrfToken + } + }).fail(function() { + console.error('Failed to save widget order'); + }); + }, + + getNavlets: function (column) { + if (column) { + return $(column).find(SELECTORS.NAVLET); + } else { + return this.container.find(SELECTORS.NAVLET); + } + }, + + isNavlet: function(node) { + return node?.dataset?.id && node.classList.contains(CSS_CLASSES.NAVLET); + }, + }; + + function initialize() { + const controller = new NavletsHtmxController(); + + // HTMX afterSwap listener + document.body.addEventListener('htmx:afterSwap', function (event) { + const swappedNode = event.detail.elt; + + const isNavletContainer = swappedNode.id === NAVLETS_CONTAINER_ID; + if (isNavletContainer) { + controller.addListeners(); + } + if (controller.isNavlet(swappedNode)) { + NavletHandlers.handle(swappedNode); + } + }); + + // Navlet added listener + document.body.addEventListener('nav.navlet.added', function (event) { + controller.updateOrder(); + const navlet = document.querySelector(`[data-id="${event.detail.navlet_id}"]`); + if (navlet) { + navlet.classList.add('mark-new'); + navlet.addEventListener("mouseenter", function () { + navlet.classList.remove('mark-new'); + }) + } + + const node = document.getElementById('no-widgets-message'); + if (node) { + node.remove(); + } + }) + + // Navlet removed listener + document.body.addEventListener('nav.navlet.removed', function (event) { + controller.updateOrder(); + }); + } + + return { + initialize: initialize + }; +}); diff --git a/python/nav/web/static/js/src/webfront.js b/python/nav/web/static/js/src/webfront.js index 54e2b6a0d6..deff11a5dd 100644 --- a/python/nav/web/static/js/src/webfront.js +++ b/python/nav/web/static/js/src/webfront.js @@ -1,21 +1,12 @@ require([ - 'plugins/room_mapper', - 'plugins/navlets_controller', - 'plugins/sensors_controller', + 'plugins/navlets_htmx_controller', 'plugins/fullscreen', 'libs/jquery-ui.min', - 'src/getting_started_wizard' -], function (RoomMapper, NavletsController, SensorsController, fullscreen, _, GettingStartedWizard) { +], function (NavletsHtmxController, fullscreen, _,) { 'use strict'; - var $navletsContainer = $('#navlets'); - var $dashboardNavigator = $('#dashboard-nav'); - - function createRoomMap(mapwrapper, room_map) { - mapwrapper.show(); - new RoomMapper(room_map.get(0)); - } - + const $navletsContainer = $('#navlets-htmx'); + const $dashboardNavigator = $('#dashboard-nav'); /** * Keyboard navigation to switch dashboards @@ -190,28 +181,6 @@ require([ } - /** Change number of columns */ - function addColumnListener() { - $('.column-chooser').click(function () { - $navletsContainer.empty(); - const columns = $(this).data('columns'); - new NavletsController($navletsContainer, columns); - // Save number of columns - const url = $(this).closest('.button-group').data('url'); - const csrfToken = $('#update-columns-form input[name=csrfmiddlewaretoken]').val(); - const request = $.ajax({ - url, - type: 'POST', - data: {num_columns: columns}, - headers: {'X-CSRFToken': csrfToken} - }); - request.done(function () { - $navletsContainer.data('widget-columns', columns); - }); - }); - } - - /** Functions for handling setting of default dashboard */ function addDefaultDashboardListener(feedback) { var defaultDashboardContainer = $('#default-dashboard-container'), @@ -310,34 +279,7 @@ require([ * Load runner - runs on page load */ $(function () { - var numColumns = $navletsContainer.data('widget-columns'); - var controller = new NavletsController($navletsContainer, numColumns); - controller.container.on('navlet-rendered', function (event, node) { - var mapwrapper = node.find('.mapwrapper'); - var room_map = mapwrapper.find('#room_map'); - if (room_map.length > 0) { - createRoomMap(mapwrapper, room_map); - } - - - if (node.hasClass('SensorWidget')) { - var sensor = new SensorsController(node.find('.room-sensor')); - } - - - }); - - - /* Add click listener to joyride button */ - $navletsContainer.on('click', '#getting-started-wizard', function () { - GettingStartedWizard.start(); - }); - - /* Need some way of doing javascript stuff on widgets */ - $navletsContainer.on('click', '.watchdog-tests .label.alert', function (event) { - $(event.target).closest('li').find('ul').toggle(); - }); - + NavletsHtmxController.initialize(); /** * DASHBOARD related stuff @@ -359,7 +301,6 @@ require([ */ var feedback = createFeedbackElements(); - addColumnListener(); addDefaultDashboardListener(feedback); addCreateDashboardListener(feedback); addRenameDashboardListener(feedback); diff --git a/python/nav/web/status2/forms.py b/python/nav/web/status2/forms.py index 8b28fc6140..d24e3f5b84 100644 --- a/python/nav/web/status2/forms.py +++ b/python/nav/web/status2/forms.py @@ -192,6 +192,13 @@ class StatusWidgetForm(StatusPanelForm): ), ) + interval = forms.IntegerField( + required=True, + min_value=1, + label='Refresh interval (seconds)', + widget=forms.NumberInput(attrs={'min': 1, 'step': 1}), + ) + def __init__(self, *args, **kwargs): super(StatusWidgetForm, self).__init__(*args, **kwargs) @@ -217,6 +224,7 @@ def __init__(self, *args, **kwargs): self.attrs = set_flat_form_attributes( form_fields=[ + FormRow(fields=[FormColumn(fields=[self["interval"]])]), FormRow( fields=[ FormColumn( diff --git a/python/nav/web/templates/navlets/_add_navlet_modal.html b/python/nav/web/templates/navlets/_add_navlet_modal.html index ae4df38704..f67077cbac 100644 --- a/python/nav/web/templates/navlets/_add_navlet_modal.html +++ b/python/nav/web/templates/navlets/_add_navlet_modal.html @@ -3,7 +3,11 @@

    Add a widget to your page

    {% for navlet in navlets %}
  • -
    + {% csrf_token %} diff --git a/python/nav/web/templates/navlets/_add_navlet_response.html b/python/nav/web/templates/navlets/_add_navlet_response.html new file mode 100644 index 0000000000..84c922a685 --- /dev/null +++ b/python/nav/web/templates/navlets/_add_navlet_response.html @@ -0,0 +1,4 @@ +
    + {% include 'navlets/_navlet_item.html' with navlet=navlet %} +
    + diff --git a/python/nav/web/templates/navlets/_navlet_item.html b/python/nav/web/templates/navlets/_navlet_item.html new file mode 100644 index 0000000000..801ec6c2d2 --- /dev/null +++ b/python/nav/web/templates/navlets/_navlet_item.html @@ -0,0 +1,27 @@ + diff --git a/python/nav/web/templates/navlets/_navlet_refresh.html b/python/nav/web/templates/navlets/_navlet_refresh.html new file mode 100644 index 0000000000..ef4ee48769 --- /dev/null +++ b/python/nav/web/templates/navlets/_navlet_refresh.html @@ -0,0 +1,32 @@ +{% if navlet and navlet.mode == "VIEW" and navlet.preferences.refresh_interval %} +{% if not navlet.ajax_reload %} + +{% else %} + +{% endif %} +{% endif %} diff --git a/python/nav/web/templates/navlets/_remove_navlet_response.html b/python/nav/web/templates/navlets/_remove_navlet_response.html new file mode 100644 index 0000000000..df95cccb69 --- /dev/null +++ b/python/nav/web/templates/navlets/_remove_navlet_response.html @@ -0,0 +1,5 @@ +{% if navlet_count == 0 %} +
    +
    No widgets added to this dashboard
    +
    +{% endif %} diff --git a/python/nav/web/templates/navlets/alert_edit.html b/python/nav/web/templates/navlets/alert_edit.html index 83f371a4ad..54a9612232 100644 --- a/python/nav/web/templates/navlets/alert_edit.html +++ b/python/nav/web/templates/navlets/alert_edit.html @@ -11,10 +11,16 @@ Choose either a sensor or write a metric name.

    - + {% include 'custom_crispy_templates/_form_content.html' %} - - Cancel + +
    {% else %} diff --git a/python/nav/web/templates/navlets/base.html b/python/nav/web/templates/navlets/base.html index f061f8e6a1..1e20f50656 100644 --- a/python/nav/web/templates/navlets/base.html +++ b/python/nav/web/templates/navlets/base.html @@ -1,3 +1,5 @@ +{% include "navlets/_navlet_refresh.html" with navlet=navlet %} +{% if navlet %}
  • - + {% if navlet.mode == 'VIEW' %} {% else %} @@ -65,11 +73,35 @@
+{% else %} + +{% endif %} diff --git a/python/nav/web/templates/navlets/envrack_edit.html b/python/nav/web/templates/navlets/envrack_edit.html index 013db57746..072a715f4a 100644 --- a/python/nav/web/templates/navlets/envrack_edit.html +++ b/python/nav/web/templates/navlets/envrack_edit.html @@ -2,7 +2,11 @@ {% block navlet-content %} -
+ {% csrf_token %} {{ form }} diff --git a/python/nav/web/templates/navlets/feedreader_edit.html b/python/nav/web/templates/navlets/feedreader_edit.html index 4facc2c44b..ebf2e852b5 100644 --- a/python/nav/web/templates/navlets/feedreader_edit.html +++ b/python/nav/web/templates/navlets/feedreader_edit.html @@ -1,7 +1,11 @@ {% extends 'navlets/base.html' %} {% block navlet-content %} - + {% csrf_token %}
diff --git a/python/nav/web/templates/navlets/graph_edit.html b/python/nav/web/templates/navlets/graph_edit.html index 378686805f..85438bdbca 100644 --- a/python/nav/web/templates/navlets/graph_edit.html +++ b/python/nav/web/templates/navlets/graph_edit.html @@ -2,7 +2,11 @@ {% block navlet-content %} - + {% include 'custom_crispy_templates/_form_content.html' %} diff --git a/python/nav/web/templates/navlets/pdu_edit.html b/python/nav/web/templates/navlets/pdu_edit.html index 013db57746..072a715f4a 100644 --- a/python/nav/web/templates/navlets/pdu_edit.html +++ b/python/nav/web/templates/navlets/pdu_edit.html @@ -2,7 +2,11 @@ {% block navlet-content %} - + {% csrf_token %} {{ form }} diff --git a/python/nav/web/templates/navlets/report_edit.html b/python/nav/web/templates/navlets/report_edit.html index 75e6ebfe7e..fb41bb8806 100644 --- a/python/nav/web/templates/navlets/report_edit.html +++ b/python/nav/web/templates/navlets/report_edit.html @@ -2,7 +2,11 @@ {% block navlet-content %} - + {% csrf_token %}