Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
09202d5
Add initial support for rendering dashboard with htmx
Simrayz Oct 23, 2025
6aed704
Add support for adding and removing navlets with htmx
Simrayz Oct 24, 2025
d2ce08e
Add loading spinner to navlets
Simrayz Oct 30, 2025
f0de085
Change column chooser to update with htmx
Simrayz Oct 24, 2025
1df67ca
Add mark-new class to new navlets
Simrayz Oct 24, 2025
bcef557
Move navlet listeners to htmx controller
Simrayz Oct 24, 2025
9d24516
Add support for saving navlet forms with HTMX
Simrayz Oct 27, 2025
2d0d392
Handle form errors in status navlet
Simrayz Oct 28, 2025
b04a8f5
Support re-rendering of form select elements with htmx
Simrayz Oct 28, 2025
a82edde
Add bust parameter to graph urls
Simrayz Oct 29, 2025
0c8d98f
Add refresh template for handling periodic refresh
Simrayz Oct 29, 2025
2717b07
Refactor navlets htmx controller
Simrayz Oct 29, 2025
e198ad4
Refactor navlet handlers in separate plugin
Simrayz Oct 29, 2025
2dd8160
Add changelog entry
Simrayz Oct 29, 2025
28f1a3a
Support ajax reload with htmx swap
Simrayz Oct 29, 2025
a287e5b
Remove old navlet controllers
Simrayz Oct 21, 2025
f4771af
Add dashboard loading tests
Simrayz Oct 29, 2025
20865d9
Add navlet tests
Simrayz Oct 30, 2025
16bc4a6
fixup! Handle form errors in status navlet
Simrayz Oct 31, 2025
eee96ac
fixup! Add support for saving navlet forms with HTMX
Simrayz Oct 31, 2025
c4f86d9
fixup! Add bust parameter to graph urls
Simrayz Oct 31, 2025
143c621
fixup! Handle form errors in status navlet
Simrayz Nov 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3635.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Refactor dashboard navlets to use HTMX for rendering and updates
155 changes: 112 additions & 43 deletions python/nav/web/navlets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,17 @@

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
from django.shortcuts import render, get_object_or_404
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
Expand Down Expand Up @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put in the class docstring what override_mode is used for.

"""
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

Expand All @@ -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"""
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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':
Expand Down
3 changes: 1 addition & 2 deletions python/nav/web/navlets/feedreader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -74,4 +73,4 @@ def post(self, request):
account_navlet.preferences['maxposts'] = maxposts
account_navlet.save()

return HttpResponse()
return self.get(request)
4 changes: 3 additions & 1 deletion python/nav/web/navlets/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions python/nav/web/navlets/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
8 changes: 4 additions & 4 deletions python/nav/web/navlets/status2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
4 changes: 0 additions & 4 deletions python/nav/web/navlets/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
remove_user_navlet_modal,
dispatcher,
save_navlet_order,
render_base_template,
set_navlet_preferences,
add_user_navlet_sensor,
)
Expand Down Expand Up @@ -65,9 +64,6 @@
),
re_path(r'^get-user-navlet/(?P<navlet_id>\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,
Expand Down
7 changes: 5 additions & 2 deletions python/nav/web/navlets/vlangraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
4 changes: 2 additions & 2 deletions python/nav/web/sass/nav/navlets.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion python/nav/web/sass/nav/navlets_compact.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#navlets.compact {
#navlets-htmx.compact {
font-size: 90%;
.navlet {
margin-bottom: 0;
Expand Down
Loading
Loading