Skip to content

Commit 864d6bf

Browse files
authored
Merge pull request #3440 from Uninett/chore/3434-use-htmx-for-device-history
Replace QuickSelect in DeviceHistory tool with HTMX
2 parents 6506ce0 + 18190f6 commit 864d6bf

File tree

12 files changed

+445
-65
lines changed

12 files changed

+445
-65
lines changed

changelog.d/3434.changed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Replace quickselect with HTMX component picker in Device history tool

python/nav/web/devicehistory/urls.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222

2323
urlpatterns = [
2424
re_path(r'^$', views.devicehistory_search, name='devicehistory-search'),
25+
re_path(
26+
r'^componentsearch/$',
27+
views.devicehistory_component_search,
28+
name='devicehistory-component-search',
29+
),
2530
re_path(r'^history/$', views.devicehistory_view, name='devicehistory-view'),
2631
re_path(
2732
r'^history/room/(?P<room_id>.+)/$',
@@ -39,6 +44,11 @@
3944
name='devicehistory-view-location',
4045
),
4146
re_path(r'^registererror/$', views.error_form, name='devicehistory-registererror'),
47+
re_path(
48+
r'^registererror/componentsearch/$',
49+
views.registererror_component_search,
50+
name='devicehistory-registererror-component-search',
51+
),
4252
re_path(
4353
r'^do_registererror/$',
4454
views.register_error,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from typing import Union
2+
3+
from django.db.models import Model, Q, QuerySet
4+
from nav.models.manage import Netbox, Module, Location, NetboxGroup, Room
5+
6+
7+
def get_component_search_results(
8+
search: str, button_text: str, exclude: list[type[Model]] = []
9+
):
10+
"""
11+
Retrieves grouped search results for component types based on a search query.
12+
13+
:param search: The search term to filter components.
14+
:param button_text: A format string for the button label, which will be formatted
15+
with the component type label. It should contain exactly one '%s' placeholder.
16+
17+
:param exclude: A list of component types to exclude from the search.
18+
Defaults to an empty list.
19+
:returns: Dictionary mapping component names to dictionaries with the
20+
following keys:
21+
22+
- **label** (*str*): Display label for the component type.
23+
- **has_grouping** (*bool*): Indicates if the results are grouped.
24+
- **values** (*list or list of tuples*): Grouped or ungrouped search results.
25+
- **button** (*str*): Label for the associated action button.
26+
"""
27+
if button_text.count('%s') != 1:
28+
raise ValueError("button_text must contain exactly one '%s' placeholder")
29+
30+
results = {}
31+
searches = _get_search_queries(search, exclude)
32+
33+
for component_type, query, group_by in searches:
34+
component_query = _get_component_query(component_type, query)
35+
component_results = _prefetch_and_group_components(
36+
component_type, component_query, group_by
37+
)
38+
39+
if component_results:
40+
component_name = component_type._meta.db_table
41+
component_label = component_type._meta.verbose_name.title()
42+
results[component_name] = {
43+
'label': component_label,
44+
'values': component_results,
45+
'has_grouping': group_by is not None,
46+
'button': button_text % component_label,
47+
}
48+
return results
49+
50+
51+
def _get_search_queries(search: str, exclude: list[Model] = []):
52+
"""
53+
Constructs a list of search queries for different component types.
54+
55+
Excludes specified component types if provided.
56+
"""
57+
searches: list[tuple[type[Model], Q, type[Model] | None]] = [
58+
(Location, Q(id__icontains=search), None),
59+
(Room, Q(id__icontains=search), Location),
60+
(
61+
Netbox,
62+
Q(sysname__icontains=search)
63+
| Q(entities__device__serial__icontains=search),
64+
Room,
65+
),
66+
(NetboxGroup, Q(id__icontains=search), None),
67+
(
68+
Module,
69+
Q(name__icontains=search) | Q(device__serial__icontains=search),
70+
Netbox,
71+
),
72+
]
73+
if exclude:
74+
searches = [
75+
(component_type, query, group_by)
76+
for component_type, query, group_by in searches
77+
if component_type not in exclude
78+
]
79+
return searches
80+
81+
82+
def _get_component_query(component_type: Model, query: Q):
83+
"""
84+
Constructs a query result for the specified component type based on
85+
the provided query.
86+
"""
87+
if component_type._meta.db_table == "netbox":
88+
return Netbox.objects.with_chassis_serials().filter(query).distinct()
89+
return component_type.objects.filter(query)
90+
91+
92+
def _prefetch_and_group_components(
93+
component_type: Model, query_results: QuerySet, group_by: Union[Model, None] = None
94+
):
95+
"""
96+
Prefetches related objects and groups the results by the specified group_by model.
97+
98+
If group_by is None, returns a flat list of component IDs and labels.
99+
If group_by is specified, groups the results by the related model's
100+
string representation.
101+
"""
102+
if group_by is None or not hasattr(component_type, group_by._meta.db_table):
103+
return [
104+
(component.id, _get_option_label(component)) for component in query_results
105+
]
106+
107+
group_by_name = group_by._meta.db_table
108+
component_results = query_results.prefetch_related(group_by_name)
109+
grouped_results = {}
110+
for component in component_results:
111+
group_by_model = getattr(component, group_by_name)
112+
group_name = str(group_by_model)
113+
option = (component.id, _get_option_label(component))
114+
115+
if group_name not in grouped_results:
116+
grouped_results[group_name] = []
117+
grouped_results[group_name].append(option)
118+
return [(group, components) for group, components in grouped_results.items()]
119+
120+
121+
def _get_option_label(component: Model):
122+
"""
123+
Returns a string representation of the component for use in option labels.
124+
"""
125+
if component._meta.db_table == 'netbox':
126+
return '%(sysname)s [%(ip)s - %(chassis_serial)s]' % component.__dict__
127+
return str(component)

python/nav/web/devicehistory/views.py

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,39 +21,26 @@
2121
from django.http import HttpResponseRedirect
2222
from django.shortcuts import render, redirect
2323
from django.urls import reverse
24+
from django.views.decorators.http import require_http_methods
2425

2526
from nav.event2 import EventFactory
2627
from nav.models.fields import INFINITY
27-
from nav.models.manage import Netbox, Module
28+
from nav.models.manage import Netbox, Module, Room, Location, NetboxGroup
2829
from nav.models.event import AlertHistory
2930
from nav.web.message import new_message, Messages
30-
from nav.web.quickselect import QuickSelect
3131
from nav.web.devicehistory.utils.history import (
3232
fetch_history,
3333
get_messages_for_history,
3434
group_history_and_messages,
3535
describe_search_params,
3636
add_descendants,
3737
)
38+
from nav.web.devicehistory.utils.componentsearch import get_component_search_results
3839
from nav.web.devicehistory.utils.error import register_error_events
3940
from nav.web.devicehistory.forms import DeviceHistoryViewFilter
4041

4142
device_event = EventFactory('ipdevpoll', 'eventEngine', 'deviceState')
4243

43-
DEVICEQUICKSELECT_VIEW_HISTORY_KWARGS = {
44-
'button': 'View %s history',
45-
'module': True,
46-
'netbox_label': '%(sysname)s [%(ip)s - %(chassis_serial)s]',
47-
}
48-
DEVICEQUICKSELECT_POST_ERROR_KWARGS = {
49-
'button': 'Add %s error event',
50-
'location': False,
51-
'room': False,
52-
'module': True,
53-
'netbox_label': '%(sysname)s [%(ip)s - %(chassis_serial)s]',
54-
}
55-
56-
5744
# Often used timelimits, in seconds:
5845
ONE_DAY = 24 * 3600
5946
ONE_WEEK = 7 * ONE_DAY
@@ -66,8 +53,6 @@
6653

6754
def devicehistory_search(request):
6855
"""Implements the device history landing page / search form"""
69-
device_quickselect = QuickSelect(**DEVICEQUICKSELECT_VIEW_HISTORY_KWARGS)
70-
7156
if 'from_date' in request.GET:
7257
form = DeviceHistoryViewFilter(request.GET)
7358
if form.is_valid():
@@ -77,14 +62,29 @@ def devicehistory_search(request):
7762

7863
info_dict = {
7964
'active': {'device': True},
80-
'quickselect': device_quickselect,
8165
'navpath': [('Home', '/'), ('Device History', '')],
8266
'title': 'NAV - Device History',
8367
'form': form,
8468
}
8569
return render(request, 'devicehistory/history_search.html', info_dict)
8670

8771

72+
@require_http_methods(["POST"])
73+
def devicehistory_component_search(request):
74+
"""HTMX endpoint for component searches from device history form"""
75+
raw_search = request.POST.get("search")
76+
search = raw_search.strip() if raw_search else ''
77+
if not search:
78+
return render(
79+
request, 'devicehistory/_component-search-results.html', {'results': {}}
80+
)
81+
82+
results = get_component_search_results(search, "View %s history")
83+
return render(
84+
request, 'devicehistory/_component-search-results.html', {'results': results}
85+
)
86+
87+
8888
def devicehistory_view_location(request, location_id):
8989
url = reverse('devicehistory-view')
9090
return redirect(url + '?loc=%s' % location_id)
@@ -152,7 +152,6 @@ def devicehistory_view(request, **_):
152152

153153
def error_form(request):
154154
"""Implements the 'register error event' form"""
155-
device_quickselect = QuickSelect(**DEVICEQUICKSELECT_POST_ERROR_KWARGS)
156155
error_comment = request.POST.get('error_comment', "")
157156

158157
return render(
@@ -161,7 +160,6 @@ def error_form(request):
161160
{
162161
'active': {'error': True},
163162
'confirm': False,
164-
'quickselect': device_quickselect,
165163
'error_comment': error_comment,
166164
'title': 'NAV - Device History - Register error',
167165
'navpath': [
@@ -172,6 +170,24 @@ def error_form(request):
172170
)
173171

174172

173+
@require_http_methods(["POST"])
174+
def registererror_component_search(request):
175+
"""HTMX endpoint for component searches from device history form"""
176+
raw_search = request.POST.get("search")
177+
search = raw_search.strip() if raw_search else ''
178+
if not search:
179+
return render(
180+
request, 'devicehistory/_component-search-results.html', {'results': {}}
181+
)
182+
183+
results = get_component_search_results(
184+
search, 'Add %s error event', [Room, Location, NetboxGroup]
185+
)
186+
return render(
187+
request, 'devicehistory/_component-search-results.html', {'results': results}
188+
)
189+
190+
175191
def confirm_error_form(request):
176192
"""Implements confirmation form for device error event registration"""
177193
selection = {

python/nav/web/static/js/src/devicehistory.js

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,39 +13,35 @@
1313
* License along with NAV. If not, see <http://www.gnu.org/licenses/>.
1414
*/
1515

16-
require(['plugins/quickselect'], function (QuickSelect) {
17-
$(document).ready(function() {
18-
var _quickselect = new QuickSelect('.quickselect');
19-
createTooltips();
20-
});
21-
22-
function createTooltips() {
23-
/**
24-
* Create tooltips on the fly
16+
$(document).ready(function() {
17+
createTooltips();
18+
});
2519

26-
* This is necessary because of the way Foundation loops through each
27-
* element and creates dom-elements on page load, thus totally killing
28-
* performance when the number of tooltips grow large.
29-
*
30-
* This solution is bare bones. It does not handle any extra options
31-
* on the element. It does not handle touch devices. Thus it is only
32-
* functional for desktop users.
33-
*/
34-
$('#device-history-search-results').on('mouseenter',
35-
'.fa-envelope, .netbox-sysname-tooltip', function (event) {
20+
function createTooltips() {
21+
/**
22+
* Create tooltips on the fly
3623
37-
var target = $(event.currentTarget);
38-
if (!target.data('selector')) {
39-
// selector data attribute is only there if create has been
40-
// run before
41-
Foundation.libs.tooltip.create(target);
42-
target.on('mouseleave', function (event) {
43-
Foundation.libs.tooltip.hide($(event.currentTarget));
44-
});
45-
}
46-
Foundation.libs.tooltip.showTip(target);
24+
* This is necessary because of the way Foundation loops through each
25+
* element and creates dom-elements on page load, thus totally killing
26+
* performance when the number of tooltips grow large.
27+
*
28+
* This solution is bare bones. It does not handle any extra options
29+
* on the element. It does not handle touch devices. Thus it is only
30+
* functional for desktop users.
31+
*/
32+
$('#device-history-search-results').on('mouseenter',
33+
'.fa-envelope, .netbox-sysname-tooltip', function (event) {
4734

48-
});
49-
}
35+
var target = $(event.currentTarget);
36+
if (!target.data('selector')) {
37+
// selector data attribute is only there if create has been
38+
// run before
39+
Foundation.libs.tooltip.create(target);
40+
target.on('mouseleave', function (event) {
41+
Foundation.libs.tooltip.hide($(event.currentTarget));
42+
});
43+
}
44+
Foundation.libs.tooltip.showTip(target);
5045

51-
});
46+
});
47+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{% if results %}
2+
{% for component_type, result in results.items %}
3+
<div>
4+
<label for="id_{{ component_type }}" style="font-weight: bold;">{{ result.label }}</label>
5+
<select multiple id="id_{{ component_type }}" name="{{ component_type }}">
6+
{% if result.has_grouping %}
7+
{% for optgroup, options in result.values %}
8+
<optgroup label="{{ optgroup }}">
9+
{% for id, label in options %}
10+
<option value="{{ id }}">{{ label }}</option>
11+
{% endfor %}
12+
</optgroup>
13+
{% endfor %}
14+
{% else %}
15+
{% for id, label in result.values %}
16+
<option value="{{ id }}">{{ label }}</option>
17+
{% endfor %}
18+
{% endif %}
19+
</select>
20+
<div class="row">
21+
<button type="submit" class="small secondary" name="submit_{component_type}">
22+
{{ result.button }}
23+
</button>
24+
<button
25+
type="button" class="secondary small"
26+
onclick="Array.from(document.getElementById('id_{{ component_type }}').options).forEach(opt => opt.selected = true);"
27+
>
28+
Select all
29+
</button>
30+
</div>
31+
</div>
32+
{% endfor %}
33+
{% else %}
34+
<strong>No hits</strong>
35+
{% endif %}

0 commit comments

Comments
 (0)