Skip to content

Commit 8045d13

Browse files
committed
[feature] Added organization filter to timeseries charts #532
- Added option to define "summary_query" for metrics - Added functionality to handle "GROUP by tags" clause to InfluxDB client Related to #532
1 parent 19163bf commit 8045d13

File tree

10 files changed

+278
-21
lines changed

10 files changed

+278
-21
lines changed

openwisp_monitoring/db/backends/influxdb/client.py

+45-3
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,24 @@ def read(self, key, fields, tags, **kwargs):
256256
return list(self.query(q, precision='s').get_points())
257257

258258
def get_list_query(self, query, precision='s'):
259-
return list(self.query(query, precision=precision).get_points())
259+
result = self.query(query, precision=precision)
260+
if not len(result.keys()) or result.keys()[0][1] is None:
261+
return list(result.get_points())
262+
# Handles query which contains "GROUP BY TAG" clause
263+
result_points = {}
264+
for (measurement, tag), group_points in result.items():
265+
tag_suffix = '_'.join(tag.values())
266+
for point in group_points:
267+
values = {}
268+
for key, value in point.items():
269+
if key != 'time':
270+
values[f'{tag_suffix}'] = value
271+
values['time'] = point['time']
272+
try:
273+
result_points[values['time']].update(values)
274+
except KeyError:
275+
result_points[values['time']] = values
276+
return list(result_points.values())
260277

261278
@retry
262279
def get_list_retention_policies(self):
@@ -329,7 +346,12 @@ def get_query(
329346
query = f'{query} LIMIT 1'
330347
return f"{query} tz('{timezone}')"
331348

332-
_group_by_regex = re.compile(r'GROUP BY time\(\w+\)', flags=re.IGNORECASE)
349+
_group_by_time_tag_regex = re.compile(
350+
r'GROUP BY ((time\(\w+\))(?:,\s+\w+)?)', flags=re.IGNORECASE
351+
)
352+
_group_by_time_regex = re.compile(r'GROUP BY time\(\w+\)\s?', flags=re.IGNORECASE)
353+
_time_regex = re.compile(r'time\(\w+\)\s?', flags=re.IGNORECASE)
354+
_time_comma_regex = re.compile(r'time\(\w+\),\s?', flags=re.IGNORECASE)
333355

334356
def _group_by(self, query, time, chart_type, group_map, strip=False):
335357
if not self.validate_query(query):
@@ -343,7 +365,27 @@ def _group_by(self, query, time, chart_type, group_map, strip=False):
343365
if 'GROUP BY' not in query.upper():
344366
query = f'{query} {group_by}'
345367
else:
346-
query = re.sub(self._group_by_regex, group_by, query)
368+
# The query could have GROUP BY clause for a TAG
369+
if group_by:
370+
# The query already contains "GROUP BY", therefore
371+
# we remove it from the "group_by" to avoid duplicating
372+
# "GROUP BY"
373+
group_by = group_by.replace('GROUP BY ', '')
374+
# We only need to substitute the time function.
375+
# The resulting query would be "GROUP BY time(<group_by>), <tag>"
376+
query = re.sub(self._time_regex, group_by, query)
377+
else:
378+
# The query should not include the "GROUP by time()"
379+
matches = re.search(self._group_by_time_tag_regex, query)
380+
group_by_fields = matches.group(1)
381+
if len(group_by_fields.split(',')) > 1:
382+
# If the query has "GROUP BY time(), tag",
383+
# then return "GROUP BY tag"
384+
query = re.sub(self._time_comma_regex, '', query)
385+
else:
386+
# If the query has only has "GROUP BY time()",
387+
# then remove the "GROUP BY" clause
388+
query = re.sub(self._group_by_time_regex, '', query)
347389
return query
348390

349391
_fields_regex = re.compile(

openwisp_monitoring/db/backends/influxdb/tests.py

+42
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,48 @@ def test_get_query_30d(self):
190190
self.assertIn(str(last30d)[0:10], q)
191191
self.assertIn('group by time(24h)', q.lower())
192192

193+
def test_group_by_tags(self):
194+
self.assertEqual(
195+
timeseries_db._group_by(
196+
'SELECT COUNT(item) FROM measurement GROUP BY time(1d)',
197+
time='30d',
198+
chart_type='stackedbar+lines',
199+
group_map={'30d': '30d'},
200+
strip=False,
201+
),
202+
'SELECT COUNT(item) FROM measurement GROUP BY time(30d)',
203+
)
204+
self.assertEqual(
205+
timeseries_db._group_by(
206+
'SELECT COUNT(item) FROM measurement GROUP BY time(1d)',
207+
time='30d',
208+
chart_type='stackedbar+lines',
209+
group_map={'30d': '30d'},
210+
strip=True,
211+
),
212+
'SELECT COUNT(item) FROM measurement ',
213+
)
214+
self.assertEqual(
215+
timeseries_db._group_by(
216+
'SELECT COUNT(item) FROM measurement GROUP BY time(1d), tag',
217+
time='30d',
218+
chart_type='stackedbar+lines',
219+
group_map={'30d': '30d'},
220+
strip=False,
221+
),
222+
'SELECT COUNT(item) FROM measurement GROUP BY time(30d), tag',
223+
)
224+
self.assertEqual(
225+
timeseries_db._group_by(
226+
'SELECT COUNT(item) FROM measurement GROUP BY time(1d), tag',
227+
time='30d',
228+
chart_type='stackedbar+lines',
229+
group_map={'30d': '30d'},
230+
strip=True,
231+
),
232+
'SELECT COUNT(item) FROM measurement GROUP BY tag',
233+
)
234+
193235
def test_retention_policy(self):
194236
manage_short_retention_policy()
195237
manage_default_retention_policy()

openwisp_monitoring/device/apps.py

+3
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,11 @@ def register_dashboard_items(self):
402402
'monitoring/css/percircle.min.css',
403403
'monitoring/css/chart.css',
404404
'monitoring/css/dashboard-chart.css',
405+
'admin/css/vendor/select2/select2.min.css',
406+
'admin/css/autocomplete.css',
405407
),
406408
'js': (
409+
'admin/js/vendor/select2/select2.full.min.js',
407410
'monitoring/js/lib/moment.min.js',
408411
'monitoring/js/lib/daterangepicker.min.js',
409412
'monitoring/js/lib/percircle.min.js',

openwisp_monitoring/monitoring/api/views.py

+28
Original file line numberDiff line numberDiff line change
@@ -166,5 +166,33 @@ def invalidate_cache(cls, instance, *args, **kwargs):
166166
return
167167
cls._get_charts.invalidate()
168168

169+
def _get_user_managed_orgs(self, request):
170+
"""
171+
Return list of dictionary containing organization name and slug
172+
in select2 compatible format.
173+
"""
174+
orgs = []
175+
qs = Organization.objects.only('slug', 'name')
176+
if not request.user.is_superuser:
177+
if len(request.user.organizations_managed) > 1:
178+
qs = qs.filter(pk__in=request.user.organizations_managed)
179+
else:
180+
return orgs
181+
for org in qs.iterator():
182+
orgs.append({'id': org.slug, 'text': org.name})
183+
if len(orgs) < 2:
184+
# Handles scenarios for superuser when the project has only
185+
# one organization.
186+
return []
187+
return orgs
188+
189+
def get(self, request, *args, **kwargs):
190+
response = super().get(request, *args, **kwargs)
191+
if not request.GET.get('csv'):
192+
user_managed_orgs = self._get_user_managed_orgs(request)
193+
if user_managed_orgs:
194+
response.data['organizations'] = user_managed_orgs
195+
return response
196+
169197

170198
dashboard_timeseries = DashboardTimeseriesView.as_view()

openwisp_monitoring/monitoring/base/models.py

+8
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,12 @@ def query(self):
601601
return query[timeseries_db.backend_name]
602602
return self._default_query
603603

604+
@property
605+
def summary_query(self):
606+
query = self.config_dict.get('summary_query', None)
607+
if query:
608+
return query[timeseries_db.backend_name]
609+
604610
@property
605611
def top_fields(self):
606612
return self.config_dict.get('top_fields', None)
@@ -659,6 +665,8 @@ def get_query(
659665
additional_params=None,
660666
):
661667
query = query or self.query
668+
if summary and self.summary_query:
669+
query = self.summary_query
662670
additional_params = additional_params or {}
663671
params = self._get_query_params(time, start_date, end_date)
664672
params.update(additional_params)

openwisp_monitoring/monitoring/static/monitoring/js/chart-utils.js

+45-11
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ django.jQuery(function ($) {
197197
url = `${url}&timezone=${timezone}`;
198198
}
199199
}
200+
if ($('#org-selector').val()) {
201+
var orgSlug = $('#org-selector').val();
202+
url = `${url}&organization_slug=${orgSlug}`;
203+
}
200204
return url;
201205
},
202206
createCharts = function (data){
@@ -213,6 +217,22 @@ django.jQuery(function ($) {
213217
createChart(chart, data.x, htmlId, chart.title, chart.type, chartQuickLink);
214218
});
215219
},
220+
addOrganizationSelector = function (data) {
221+
var orgSelector = $('#org-selector');
222+
if (data.organizations === undefined) {
223+
orgSelector.hide();
224+
return;
225+
}
226+
if (orgSelector.data('select2-id') === 'org-selector') {
227+
return;
228+
}
229+
orgSelector.select2({
230+
data: data.organizations,
231+
allowClear: true,
232+
placeholder: gettext('Organization Filter')
233+
});
234+
orgSelector.show();
235+
},
216236
loadCharts = function (time, showLoading) {
217237
$.ajax(getChartFetchUrl(time), {
218238
dataType: 'json',
@@ -233,6 +253,7 @@ django.jQuery(function ($) {
233253
fallback.show();
234254
}
235255
createCharts(data);
256+
addOrganizationSelector(data);
236257
},
237258
error: function () {
238259
alert('Something went wrong while loading the charts');
@@ -318,24 +339,30 @@ django.jQuery(function ($) {
318339
});
319340
// bind export button
320341
$('#ow-chart-time a.export').click(function () {
321-
var time = localStorage.getItem(timeRangeKey);
322-
location.href = baseUrl + time + '&csv=1';
342+
var queryString,
343+
queryParams = {'csv': 1};
344+
queryParams.time = localStorage.getItem(timeRangeKey);
323345
// If custom or pickerChosenLabelKey is 'Custom Range', pass pickerEndDate and pickerStartDate to csv url
324346
if (localStorage.getItem(isCustomDateRange) === 'true' || localStorage.getItem(pickerChosenLabelKey) === customDateRangeLabel) {
325-
var startDate = localStorage.getItem(startDateTimeKey);
326-
var endDate = localStorage.getItem(endDateTimeKey);
347+
queryParams.start = localStorage.getItem(startDateTimeKey);
348+
queryParams.end = localStorage.getItem(endDateTimeKey);
327349
if (localStorage.getItem(isChartZoomed) === 'true') {
328-
time = localStorage.getItem(zoomtimeRangeKey);
329-
endDate = localStorage.getItem(zoomEndDateTimeKey);
330-
startDate = localStorage.getItem(zoomStartDateTimeKey);
350+
queryParams.time = localStorage.getItem(zoomtimeRangeKey);
351+
queryParams.end = localStorage.getItem(zoomEndDateTimeKey);
352+
queryParams.start = localStorage.getItem(zoomStartDateTimeKey);
331353
}
332-
var url = `${apiUrl}?start=${startDate}&end=${endDate}&csv=1`;
333-
var timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
354+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
334355
if (timezone) {
335-
url = `${url}&timezone=${timezone}`;
356+
queryParams.timezone = timezone;
336357
}
337-
location.href = url;
338358
}
359+
if ($('#org-selector').val()) {
360+
queryParams.organization_slug = $('#org-selector').val();
361+
}
362+
queryString = Object.keys(queryParams)
363+
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
364+
.join('&');
365+
location.href = `${apiUrl}?${queryString}`;
339366
});
340367
// fetch chart data and replace the old charts with the new ones
341368
function loadFetchedCharts(time){
@@ -352,5 +379,12 @@ django.jQuery(function ($) {
352379
},
353380
});
354381
}
382+
383+
$('#org-selector').change(function(){
384+
loadCharts(
385+
localStorage.getItem(timeRangeKey) || defaultTimeRange,
386+
true
387+
);
388+
});
355389
});
356390
}(django.jQuery));

openwisp_monitoring/monitoring/templates/monitoring/paritals/chart.html

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
<a id="daterangepicker-widget">
1313
<span></span>
1414
</a>
15+
<select name="org-selector" id="org-selector" class="hide">
16+
<option></option>
17+
</select>
1518
<div id="chart-loading-overlay"><div class="ow-loading-spinner"></div></div>
1619
<div id="ow-chart-contents"></div>
1720
<div id="ow-chart-fallback" class="form-row">

openwisp_monitoring/monitoring/tests/__init__.py

+19
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,25 @@
136136
)
137137
},
138138
},
139+
'group_by_tag': {
140+
'type': 'stackedbars',
141+
'title': 'Group by tag',
142+
'description': 'Query is groupped by tag along with time',
143+
'unit': 'n.',
144+
'order': 999,
145+
'query': {
146+
'influxdb': (
147+
"SELECT CUMULATIVE_SUM(SUM({field_name})) FROM {key} WHERE time >= '{time}'"
148+
" GROUP BY time(1d), metric_num"
149+
)
150+
},
151+
'summary_query': {
152+
'influxdb': (
153+
"SELECT SUM({field_name}) FROM {key} WHERE time >= '{time}'"
154+
" GROUP BY time(30d), metric_num"
155+
)
156+
},
157+
},
139158
'mean_test': {
140159
'type': 'line',
141160
'title': 'Mean test',

openwisp_monitoring/monitoring/tests/test_api.py

+51-7
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,12 @@ def _create_org_traffic_metric(self, org, interface_name):
6868

6969
def _create_test_users(self, org):
7070
admin = self._create_admin()
71-
org2_administrator = self._create_user()
72-
OrganizationUser.objects.create(
73-
user=org2_administrator, organization=org, is_admin=True
74-
)
71+
org_admin = self._create_user()
72+
OrganizationUser.objects.create(user=org_admin, organization=org, is_admin=True)
7573
groups = Group.objects.filter(name='Administrator')
76-
org2_administrator.groups.set(groups)
74+
org_admin.groups.set(groups)
7775
operator = self._create_operator()
78-
return admin, org2_administrator, operator
76+
return admin, org_admin, operator
7977

8078
def test_wifi_client_chart(self):
8179
def _test_chart_properties(chart):
@@ -242,7 +240,7 @@ def _test_chart_properties(chart):
242240

243241
self.client.force_login(admin)
244242
with self.subTest('Test superuser retrieves metric for all organizations'):
245-
with self.assertNumQueries(2):
243+
with self.assertNumQueries(3):
246244
response = self.client.get(path)
247245
self.assertEqual(response.status_code, 200)
248246
self._test_response_data(response)
@@ -675,3 +673,49 @@ def test_group_by_time(self):
675673
with self.subTest('Test with invalid group time'):
676674
response = self.client.get(path, {'time': '3w'})
677675
self.assertEqual(response.status_code, 400)
676+
677+
def test_organizations_list(self):
678+
path = reverse('monitoring_general:api_dashboard_timeseries')
679+
Organization.objects.all().delete()
680+
org1 = self._create_org(name='org1', slug='org1')
681+
admin, org_admin, _ = self._create_test_users(org1)
682+
683+
self.client.force_login(admin)
684+
with self.subTest('Superuser: Only one organization is present'):
685+
response = self.client.get(path)
686+
self.assertEqual(response.status_code, 200)
687+
self.assertNotIn('organizations', response.data)
688+
689+
org2 = self._create_org(name='org2', slug='org2', id=self.org2_id)
690+
691+
with self.subTest('Superuser: Multiple organizations are present'):
692+
response = self.client.get(path)
693+
self.assertEqual(response.status_code, 200)
694+
self.assertIn('organizations', response.data)
695+
self.assertEqual(len(response.data['organizations']), 2)
696+
self.assertEqual(
697+
response.data['organizations'],
698+
[{'id': org.slug, 'text': org.name} for org in [org1, org2]],
699+
)
700+
701+
self.client.logout()
702+
self.client.force_login(org_admin)
703+
704+
with self.subTest('Non-superuser: Administrator of one organization'):
705+
response = self.client.get(path)
706+
self.assertEqual(response.status_code, 200)
707+
self.assertNotIn('organizations', response.data)
708+
709+
OrganizationUser.objects.create(
710+
user=org_admin, organization=org2, is_admin=True
711+
)
712+
713+
with self.subTest('Non-superuser: Administrator of multiple organizations'):
714+
response = self.client.get(path)
715+
self.assertEqual(response.status_code, 200)
716+
self.assertIn('organizations', response.data)
717+
self.assertEqual(len(response.data['organizations']), 2)
718+
self.assertEqual(
719+
response.data['organizations'],
720+
[{'id': org.slug, 'text': org.name} for org in [org1, org2]],
721+
)

0 commit comments

Comments
 (0)