setu-trafficmonitor 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. setu_trafficmonitor-2.0.0.dist-info/LICENSE +21 -0
  2. setu_trafficmonitor-2.0.0.dist-info/METADATA +401 -0
  3. setu_trafficmonitor-2.0.0.dist-info/RECORD +42 -0
  4. setu_trafficmonitor-2.0.0.dist-info/WHEEL +5 -0
  5. setu_trafficmonitor-2.0.0.dist-info/top_level.txt +1 -0
  6. trafficmonitor/__init__.py +11 -0
  7. trafficmonitor/admin.py +217 -0
  8. trafficmonitor/analytics/__init__.py +0 -0
  9. trafficmonitor/analytics/enhanced_queries.py +286 -0
  10. trafficmonitor/analytics/serializers.py +238 -0
  11. trafficmonitor/analytics/tests.py +757 -0
  12. trafficmonitor/analytics/urls.py +18 -0
  13. trafficmonitor/analytics/views.py +694 -0
  14. trafficmonitor/apps.py +7 -0
  15. trafficmonitor/circuit_breaker.py +63 -0
  16. trafficmonitor/conf.py +154 -0
  17. trafficmonitor/dashboard_security.py +111 -0
  18. trafficmonitor/db_utils.py +37 -0
  19. trafficmonitor/exceptions.py +93 -0
  20. trafficmonitor/health.py +66 -0
  21. trafficmonitor/load_test.py +423 -0
  22. trafficmonitor/load_test_api.py +307 -0
  23. trafficmonitor/management/__init__.py +1 -0
  24. trafficmonitor/management/commands/__init__.py +1 -0
  25. trafficmonitor/management/commands/cleanup_request_logs.py +77 -0
  26. trafficmonitor/middleware.py +383 -0
  27. trafficmonitor/migrations/0001_initial.py +93 -0
  28. trafficmonitor/migrations/__init__.py +0 -0
  29. trafficmonitor/models.py +206 -0
  30. trafficmonitor/monitoring.py +104 -0
  31. trafficmonitor/permissions.py +64 -0
  32. trafficmonitor/security.py +180 -0
  33. trafficmonitor/settings_production.py +105 -0
  34. trafficmonitor/static/analytics/css/dashboard.css +99 -0
  35. trafficmonitor/static/analytics/js/dashboard-production.js +339 -0
  36. trafficmonitor/static/analytics/js/dashboard-v2.js +697 -0
  37. trafficmonitor/static/analytics/js/dashboard.js +693 -0
  38. trafficmonitor/tasks.py +137 -0
  39. trafficmonitor/templates/analytics/dashboard.html +500 -0
  40. trafficmonitor/tests.py +246 -0
  41. trafficmonitor/views.py +3 -0
  42. trafficmonitor/websocket_consumers.py +128 -0
@@ -0,0 +1,693 @@
1
+ /**
2
+ * Analytics Dashboard JavaScript
3
+ * Handles data fetching, chart rendering, and interactions
4
+ */
5
+
6
+ /**
7
+ * Get CSRF token from cookie
8
+ */
9
+ function getCookie(name) {
10
+ let cookieValue = null;
11
+ if (document.cookie && document.cookie !== '') {
12
+ const cookies = document.cookie.split(';');
13
+ for (let i = 0; i < cookies.length; i++) {
14
+ const cookie = cookies[i].trim();
15
+ if (cookie.substring(0, name.length + 1) === (name + '=')) {
16
+ cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
17
+ break;
18
+ }
19
+ }
20
+ }
21
+ return cookieValue;
22
+ }
23
+
24
+ // Chart instances (global)
25
+ let requestsOverTimeChart = null;
26
+ let statusCodesChart = null;
27
+ let methodsChart = null;
28
+ let errorTrendChart = null;
29
+ let slowestEndpointsChart = null;
30
+ let hourlyHeatmapChart = null;
31
+
32
+ // Chart color schemes
33
+ const COLORS = {
34
+ primary: 'rgb(59, 130, 246)', // Blue
35
+ success: 'rgb(34, 197, 94)', // Green
36
+ warning: 'rgb(251, 191, 36)', // Yellow
37
+ danger: 'rgb(239, 68, 68)', // Red
38
+ info: 'rgb(139, 92, 246)', // Purple
39
+ secondary: 'rgb(107, 114, 128)', // Gray
40
+ };
41
+
42
+ const METHOD_COLORS = {
43
+ 'GET': COLORS.success,
44
+ 'POST': COLORS.primary,
45
+ 'PUT': COLORS.warning,
46
+ 'PATCH': 'rgb(249, 115, 22)', // Orange
47
+ 'DELETE': COLORS.danger,
48
+ };
49
+
50
+ const STATUS_COLORS = {
51
+ '2xx': COLORS.success,
52
+ '3xx': COLORS.info,
53
+ '4xx': COLORS.warning,
54
+ '5xx': COLORS.danger,
55
+ };
56
+
57
+ /**
58
+ * Initialize the dashboard
59
+ */
60
+ function initializeDashboard() {
61
+ loadAnalyticsData();
62
+ }
63
+
64
+ /**
65
+ * Refresh dashboard with current filters
66
+ */
67
+ function refreshDashboard() {
68
+ loadAnalyticsData();
69
+ }
70
+
71
+ /**
72
+ * Apply filters and reload dashboard
73
+ */
74
+ function applyFilters() {
75
+ loadAnalyticsData();
76
+ }
77
+
78
+ /**
79
+ * Get current filter parameters
80
+ */
81
+ function getFilterParams() {
82
+ const params = new URLSearchParams();
83
+
84
+ const range = document.getElementById('rangeSelect').value;
85
+ params.append('range', range);
86
+
87
+ if (range === 'custom') {
88
+ const startDate = document.getElementById('startDate').value;
89
+ const endDate = document.getElementById('endDate').value;
90
+ if (startDate) params.append('start_date', startDate);
91
+ if (endDate) params.append('end_date', endDate);
92
+ }
93
+
94
+ const method = document.getElementById('methodFilter').value;
95
+ if (method) params.append('method', method);
96
+
97
+ const status = document.getElementById('statusFilter').value;
98
+ if (status) params.append('status', status);
99
+
100
+ const path = document.getElementById('pathFilter').value;
101
+ if (path) params.append('path', path);
102
+
103
+ return params;
104
+ }
105
+
106
+ /**
107
+ * Load analytics data from API
108
+ */
109
+ async function loadAnalyticsData() {
110
+ try {
111
+ // Show loading indicator
112
+ document.getElementById('loadingIndicator').classList.remove('hidden');
113
+ document.getElementById('dashboardContent').classList.add('hidden');
114
+
115
+ // Fetch analytics data
116
+ const params = getFilterParams();
117
+ const csrftoken = getCookie('csrftoken');
118
+ const response = await fetch(`/api/analytics/overview/?${params.toString()}`, {
119
+ credentials: 'same-origin', // Include cookies for session auth
120
+ headers: {
121
+ 'Accept': 'application/json',
122
+ 'X-CSRFToken': csrftoken, // Include CSRF token for Django
123
+ }
124
+ });
125
+
126
+ if (!response.ok) {
127
+ throw new Error(`HTTP error! status: ${response.status}`);
128
+ }
129
+
130
+ const data = await response.json();
131
+
132
+ // Update dashboard with data
133
+ updateStats(data);
134
+ updateCharts(data);
135
+ updateTables(data);
136
+
137
+ // Update last updated time
138
+ document.getElementById('lastUpdated').textContent = new Date().toLocaleString();
139
+
140
+ // Hide loading, show content
141
+ document.getElementById('loadingIndicator').classList.add('hidden');
142
+ document.getElementById('dashboardContent').classList.remove('hidden');
143
+
144
+ } catch (error) {
145
+ console.error('Error loading analytics data:', error);
146
+ alert('Failed to load analytics data. Please try again.');
147
+ document.getElementById('loadingIndicator').classList.add('hidden');
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Update stats cards
153
+ */
154
+ function updateStats(data) {
155
+ // Total requests
156
+ document.getElementById('totalToday').textContent =
157
+ formatNumber(data.totals.today);
158
+ document.getElementById('totalLast7Days').textContent =
159
+ formatNumber(data.totals.last_7_days);
160
+ document.getElementById('totalLast30Days').textContent =
161
+ formatNumber(data.totals.last_30_days);
162
+
163
+ // Average response time
164
+ const avgTime = data.performance.avg_response_time;
165
+ document.getElementById('avgResponseTime').textContent =
166
+ avgTime ? `${avgTime.toFixed(1)} ms` : 'N/A';
167
+
168
+ // Status code summary
169
+ document.getElementById('statusSuccess').textContent =
170
+ formatNumber(data.status_summary.success);
171
+ document.getElementById('statusRedirect').textContent =
172
+ formatNumber(data.status_summary.redirect);
173
+ document.getElementById('statusClientError').textContent =
174
+ formatNumber(data.status_summary.client_error);
175
+ document.getElementById('statusServerError').textContent =
176
+ formatNumber(data.status_summary.server_error);
177
+ document.getElementById('statusTotal').textContent =
178
+ formatNumber(data.status_summary.total);
179
+ }
180
+
181
+ /**
182
+ * Update all charts
183
+ */
184
+ function updateCharts(data) {
185
+ renderRequestsOverTimeChart(data.requests_over_time);
186
+ renderStatusCodesChart(data.status_codes);
187
+ renderMethodsChart(data.methods);
188
+ renderErrorTrendChart(data.error_trend);
189
+ renderSlowestEndpointsChart(data.slowest_endpoints);
190
+ renderHourlyHeatmapChart(data.hourly_heatmap);
191
+ }
192
+
193
+ /**
194
+ * Update tables
195
+ */
196
+ function updateTables(data) {
197
+ updateTopEndpointsTable(data.top_endpoints);
198
+ updateTopIPsTable(data.top_ips);
199
+ }
200
+
201
+ /**
202
+ * Render requests over time chart (Line chart)
203
+ */
204
+ function renderRequestsOverTimeChart(data) {
205
+ const ctx = document.getElementById('requestsOverTimeChart');
206
+
207
+ // Destroy existing chart
208
+ if (requestsOverTimeChart) {
209
+ requestsOverTimeChart.destroy();
210
+ }
211
+
212
+ requestsOverTimeChart = new Chart(ctx, {
213
+ type: 'line',
214
+ data: {
215
+ labels: data.map(item => new Date(item.period)),
216
+ datasets: [{
217
+ label: 'Requests',
218
+ data: data.map(item => item.count),
219
+ borderColor: COLORS.primary,
220
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
221
+ borderWidth: 2,
222
+ fill: true,
223
+ tension: 0.4,
224
+ }]
225
+ },
226
+ options: {
227
+ responsive: true,
228
+ maintainAspectRatio: false,
229
+ plugins: {
230
+ legend: {
231
+ display: false
232
+ },
233
+ tooltip: {
234
+ mode: 'index',
235
+ intersect: false,
236
+ }
237
+ },
238
+ scales: {
239
+ x: {
240
+ type: 'time',
241
+ time: {
242
+ unit: 'day',
243
+ displayFormats: {
244
+ day: 'MMM dd'
245
+ }
246
+ },
247
+ grid: {
248
+ display: false
249
+ }
250
+ },
251
+ y: {
252
+ beginAtZero: true,
253
+ ticks: {
254
+ precision: 0
255
+ },
256
+ grid: {
257
+ color: 'rgba(0, 0, 0, 0.05)'
258
+ }
259
+ }
260
+ }
261
+ }
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Render status codes chart (Bar chart)
267
+ */
268
+ function renderStatusCodesChart(data) {
269
+ const ctx = document.getElementById('statusCodesChart');
270
+
271
+ if (statusCodesChart) {
272
+ statusCodesChart.destroy();
273
+ }
274
+
275
+ // Sort by status code
276
+ data.sort((a, b) => a.status_code - b.status_code);
277
+
278
+ // Assign colors based on status code range
279
+ const colors = data.map(item => {
280
+ if (item.status_code < 300) return COLORS.success;
281
+ if (item.status_code < 400) return COLORS.info;
282
+ if (item.status_code < 500) return COLORS.warning;
283
+ return COLORS.danger;
284
+ });
285
+
286
+ statusCodesChart = new Chart(ctx, {
287
+ type: 'bar',
288
+ data: {
289
+ labels: data.map(item => item.status_code.toString()),
290
+ datasets: [{
291
+ label: 'Requests',
292
+ data: data.map(item => item.count),
293
+ backgroundColor: colors,
294
+ borderWidth: 0,
295
+ }]
296
+ },
297
+ options: {
298
+ responsive: true,
299
+ maintainAspectRatio: false,
300
+ plugins: {
301
+ legend: {
302
+ display: false
303
+ },
304
+ tooltip: {
305
+ callbacks: {
306
+ label: function(context) {
307
+ return `Requests: ${formatNumber(context.parsed.y)}`;
308
+ }
309
+ }
310
+ }
311
+ },
312
+ scales: {
313
+ y: {
314
+ beginAtZero: true,
315
+ ticks: {
316
+ precision: 0
317
+ }
318
+ }
319
+ }
320
+ }
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Render HTTP methods chart (Pie chart)
326
+ */
327
+ function renderMethodsChart(data) {
328
+ const ctx = document.getElementById('methodsChart');
329
+
330
+ if (methodsChart) {
331
+ methodsChart.destroy();
332
+ }
333
+
334
+ const colors = data.map(item => METHOD_COLORS[item.method] || COLORS.secondary);
335
+
336
+ methodsChart = new Chart(ctx, {
337
+ type: 'doughnut',
338
+ data: {
339
+ labels: data.map(item => item.method),
340
+ datasets: [{
341
+ data: data.map(item => item.count),
342
+ backgroundColor: colors,
343
+ borderWidth: 2,
344
+ borderColor: '#fff',
345
+ }]
346
+ },
347
+ options: {
348
+ responsive: true,
349
+ maintainAspectRatio: false,
350
+ plugins: {
351
+ legend: {
352
+ position: 'bottom',
353
+ },
354
+ tooltip: {
355
+ callbacks: {
356
+ label: function(context) {
357
+ const label = context.label || '';
358
+ const value = formatNumber(context.parsed);
359
+ const total = context.dataset.data.reduce((a, b) => a + b, 0);
360
+ const percentage = ((context.parsed / total) * 100).toFixed(1);
361
+ return `${label}: ${value} (${percentage}%)`;
362
+ }
363
+ }
364
+ }
365
+ }
366
+ }
367
+ });
368
+ }
369
+
370
+ /**
371
+ * Render error trend chart (Line chart)
372
+ */
373
+ function renderErrorTrendChart(data) {
374
+ const ctx = document.getElementById('errorTrendChart');
375
+
376
+ if (errorTrendChart) {
377
+ errorTrendChart.destroy();
378
+ }
379
+
380
+ errorTrendChart = new Chart(ctx, {
381
+ type: 'line',
382
+ data: {
383
+ labels: data.map(item => new Date(item.period)),
384
+ datasets: [
385
+ {
386
+ label: '4xx Client Errors',
387
+ data: data.map(item => item.client_errors),
388
+ borderColor: COLORS.warning,
389
+ backgroundColor: 'rgba(251, 191, 36, 0.1)',
390
+ borderWidth: 2,
391
+ tension: 0.4,
392
+ },
393
+ {
394
+ label: '5xx Server Errors',
395
+ data: data.map(item => item.server_errors),
396
+ borderColor: COLORS.danger,
397
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
398
+ borderWidth: 2,
399
+ tension: 0.4,
400
+ }
401
+ ]
402
+ },
403
+ options: {
404
+ responsive: true,
405
+ maintainAspectRatio: false,
406
+ plugins: {
407
+ legend: {
408
+ position: 'bottom',
409
+ },
410
+ tooltip: {
411
+ mode: 'index',
412
+ intersect: false,
413
+ }
414
+ },
415
+ scales: {
416
+ x: {
417
+ type: 'time',
418
+ time: {
419
+ unit: 'day',
420
+ displayFormats: {
421
+ day: 'MMM dd'
422
+ }
423
+ }
424
+ },
425
+ y: {
426
+ beginAtZero: true,
427
+ ticks: {
428
+ precision: 0
429
+ }
430
+ }
431
+ }
432
+ }
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Render slowest endpoints chart (Horizontal bar chart)
438
+ */
439
+ function renderSlowestEndpointsChart(data) {
440
+ const ctx = document.getElementById('slowestEndpointsChart');
441
+
442
+ if (slowestEndpointsChart) {
443
+ slowestEndpointsChart.destroy();
444
+ }
445
+
446
+ // Truncate long paths for display
447
+ const labels = data.map(item => {
448
+ const path = item.path;
449
+ return path.length > 50 ? path.substring(0, 47) + '...' : path;
450
+ });
451
+
452
+ slowestEndpointsChart = new Chart(ctx, {
453
+ type: 'bar',
454
+ data: {
455
+ labels: labels,
456
+ datasets: [{
457
+ label: 'Avg Response Time (ms)',
458
+ data: data.map(item => item.avg_response_time),
459
+ backgroundColor: COLORS.orange,
460
+ borderWidth: 0,
461
+ }]
462
+ },
463
+ options: {
464
+ indexAxis: 'y',
465
+ responsive: true,
466
+ maintainAspectRatio: false,
467
+ plugins: {
468
+ legend: {
469
+ display: false
470
+ },
471
+ tooltip: {
472
+ callbacks: {
473
+ label: function(context) {
474
+ return `Avg: ${context.parsed.x.toFixed(2)} ms`;
475
+ },
476
+ afterLabel: function(context) {
477
+ const item = data[context.dataIndex];
478
+ return `Requests: ${formatNumber(item.count)}`;
479
+ }
480
+ }
481
+ }
482
+ },
483
+ scales: {
484
+ x: {
485
+ beginAtZero: true,
486
+ title: {
487
+ display: true,
488
+ text: 'Response Time (ms)'
489
+ }
490
+ }
491
+ }
492
+ }
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Render hourly heatmap chart (Bar chart)
498
+ */
499
+ function renderHourlyHeatmapChart(data) {
500
+ const ctx = document.getElementById('hourlyHeatmapChart');
501
+
502
+ if (hourlyHeatmapChart) {
503
+ hourlyHeatmapChart.destroy();
504
+ }
505
+
506
+ // Create array for all 24 hours
507
+ const hourlyData = Array(24).fill(0);
508
+ data.forEach(item => {
509
+ hourlyData[item.hour] = item.count;
510
+ });
511
+
512
+ // Generate gradient colors based on intensity
513
+ const maxCount = Math.max(...hourlyData);
514
+ const colors = hourlyData.map(count => {
515
+ const intensity = maxCount > 0 ? count / maxCount : 0;
516
+ return `rgba(59, 130, 246, ${0.3 + intensity * 0.7})`;
517
+ });
518
+
519
+ hourlyHeatmapChart = new Chart(ctx, {
520
+ type: 'bar',
521
+ data: {
522
+ labels: Array.from({length: 24}, (_, i) => `${i}:00`),
523
+ datasets: [{
524
+ label: 'Requests',
525
+ data: hourlyData,
526
+ backgroundColor: colors,
527
+ borderWidth: 0,
528
+ }]
529
+ },
530
+ options: {
531
+ responsive: true,
532
+ maintainAspectRatio: false,
533
+ plugins: {
534
+ legend: {
535
+ display: false
536
+ },
537
+ tooltip: {
538
+ callbacks: {
539
+ label: function(context) {
540
+ return `Requests: ${formatNumber(context.parsed.y)}`;
541
+ }
542
+ }
543
+ }
544
+ },
545
+ scales: {
546
+ x: {
547
+ title: {
548
+ display: true,
549
+ text: 'Hour of Day'
550
+ }
551
+ },
552
+ y: {
553
+ beginAtZero: true,
554
+ ticks: {
555
+ precision: 0
556
+ },
557
+ title: {
558
+ display: true,
559
+ text: 'Request Count'
560
+ }
561
+ }
562
+ }
563
+ }
564
+ });
565
+ }
566
+
567
+ /**
568
+ * Update top endpoints table
569
+ */
570
+ function updateTopEndpointsTable(data) {
571
+ const tbody = document.getElementById('topEndpointsTable');
572
+ tbody.innerHTML = '';
573
+
574
+ if (data.length === 0) {
575
+ tbody.innerHTML = '<tr><td colspan="3" class="px-4 py-3 text-center text-gray-500">No data available</td></tr>';
576
+ return;
577
+ }
578
+
579
+ data.forEach((item, index) => {
580
+ const row = document.createElement('tr');
581
+ row.className = index % 2 === 0 ? 'bg-white' : 'bg-gray-50';
582
+
583
+ const pathCell = document.createElement('td');
584
+ pathCell.className = 'px-4 py-3 text-sm text-gray-900';
585
+ pathCell.textContent = item.path.length > 40 ?
586
+ item.path.substring(0, 37) + '...' : item.path;
587
+ pathCell.title = item.path;
588
+
589
+ const countCell = document.createElement('td');
590
+ countCell.className = 'px-4 py-3 text-sm text-gray-900 text-right font-semibold';
591
+ countCell.textContent = formatNumber(item.count);
592
+
593
+ const timeCell = document.createElement('td');
594
+ timeCell.className = 'px-4 py-3 text-sm text-right';
595
+ if (item.avg_response_time) {
596
+ const time = item.avg_response_time;
597
+ timeCell.innerHTML = getColoredResponseTime(time);
598
+ } else {
599
+ timeCell.textContent = '-';
600
+ }
601
+
602
+ row.appendChild(pathCell);
603
+ row.appendChild(countCell);
604
+ row.appendChild(timeCell);
605
+ tbody.appendChild(row);
606
+ });
607
+ }
608
+
609
+ /**
610
+ * Update top IPs table
611
+ */
612
+ function updateTopIPsTable(data) {
613
+ const tbody = document.getElementById('topIPsTable');
614
+ tbody.innerHTML = '';
615
+
616
+ if (data.length === 0) {
617
+ tbody.innerHTML = '<tr><td colspan="2" class="px-4 py-3 text-center text-gray-500">No data available</td></tr>';
618
+ return;
619
+ }
620
+
621
+ data.forEach((item, index) => {
622
+ const row = document.createElement('tr');
623
+ row.className = index % 2 === 0 ? 'bg-white' : 'bg-gray-50';
624
+
625
+ const ipCell = document.createElement('td');
626
+ ipCell.className = 'px-4 py-3 text-sm text-gray-900 font-mono';
627
+ ipCell.textContent = item.ip_address;
628
+
629
+ const countCell = document.createElement('td');
630
+ countCell.className = 'px-4 py-3 text-sm text-gray-900 text-right font-semibold';
631
+ countCell.textContent = formatNumber(item.count);
632
+
633
+ row.appendChild(ipCell);
634
+ row.appendChild(countCell);
635
+ tbody.appendChild(row);
636
+ });
637
+ }
638
+
639
+ /**
640
+ * Helper: Format numbers with commas
641
+ */
642
+ function formatNumber(num) {
643
+ if (num === null || num === undefined) return '0';
644
+ return num.toLocaleString();
645
+ }
646
+
647
+ /**
648
+ * Helper: Get colored response time HTML
649
+ */
650
+ function getColoredResponseTime(time) {
651
+ let color;
652
+ if (time < 100) {
653
+ color = 'text-green-600';
654
+ } else if (time < 500) {
655
+ color = 'text-yellow-600';
656
+ } else if (time < 1000) {
657
+ color = 'text-orange-600';
658
+ } else {
659
+ color = 'text-red-600';
660
+ }
661
+
662
+ return `<span class="${color} font-semibold">${time.toFixed(1)} ms</span>`;
663
+ }
664
+
665
+ /**
666
+ * Helper: Get common chart options
667
+ */
668
+ function getCommonChartOptions() {
669
+ return {
670
+ responsive: true,
671
+ maintainAspectRatio: false,
672
+ plugins: {
673
+ legend: {
674
+ position: 'bottom',
675
+ }
676
+ }
677
+ };
678
+ }
679
+
680
+ /**
681
+ * Export analytics data as CSV
682
+ */
683
+ function exportAsCSV() {
684
+ // This would require server-side implementation
685
+ alert('CSV export functionality - to be implemented');
686
+ }
687
+
688
+ /**
689
+ * Print dashboard
690
+ */
691
+ function printDashboard() {
692
+ window.print();
693
+ }