Added graphical statistics to domain detail page.

This commit is contained in:
Antoine Nguyen
2021-03-12 15:52:54 +01:00
parent a9ed6b0fcb
commit 841442ca4c
15 changed files with 461 additions and 30 deletions
+2
View File
@@ -9,11 +9,13 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"apexcharts": "^3.25.0",
"core-js": "^3.6.5",
"js-cookie": "^2.2.1",
"luxon": "^1.26.0",
"vee-validate": "^3.4.5",
"vue": "^2.6.11",
"vue-apexcharts": "^1.6.0",
"vue-cookie": "^1.1.4",
"vue-gettext": "^2.1.10",
"vue-router": "^3.2.0",
+17
View File
@@ -0,0 +1,17 @@
import repository from './repository'
const resource = 'statistics'
export default {
getStatistics ({ graphSet, period, graphicName, searchQuery, start, end }) {
const args = {
gset: graphSet,
period,
graphic: graphicName,
searchquery: searchQuery,
start: start,
end: end
}
return repository.get(`${resource}/`, { params: args })
}
}
@@ -1,5 +1,8 @@
<template>
<v-card>
<v-card-title>
<translate class="headline">General information</translate>
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="6"><translate>Creation date</translate></v-col>
@@ -0,0 +1,43 @@
<template>
<v-menu
v-model="menu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
min-width="auto"
>
<template v-slot:activator="{ on, attrs }">
<v-text-field
:value="value"
:label="label"
prepend-icon="mdi-calendar"
readonly
v-bind="attrs"
v-on="on"
></v-text-field>
</template>
<v-date-picker
:value="value"
@input="update"
no-title
>
</v-date-picker>
</v-menu>
</template>
<script>
export default {
props: ['value', 'label'],
data () {
return {
menu: false
}
},
methods: {
update (value) {
this.$emit('input', value)
this.menu = false
}
}
}
</script>
@@ -0,0 +1,162 @@
<template>
<v-card>
<v-toolbar dense elevation="0">
<v-toolbar-title>{{ title }}</v-toolbar-title>
<v-spacer />
<v-menu
v-model="menu"
:nudge-width="200"
offset-y
>
<template v-slot:activator="{ on, attrs }">
<v-btn
small
v-bind="attrs"
v-on="on"
>
<v-icon small>mdi-calendar</v-icon>
<translate>Period</translate>
<span v-if="period">: {{ period }}</span>
</v-btn>
</template>
<v-layout>
<v-row>
<v-col cols="6" class="d-flex flex-column align-start py-3 px-6">
<translate class="subtitle">Absolute time range</translate>
<date-field v-model="start" :label="'From'|translate" />
<date-field v-model="end" :label="'To'|translate" />
<v-btn color="primary" @click="setCustomPeriod"><translate>Apply</translate></v-btn>
</v-col>
<v-col cols="6">
<translate class="subtitle">Relative time ranges</translate>
<v-list>
<v-list-item
v-for="period in periods"
:key="period.value"
@click="setPeriod(period.value)"
>
<v-list-item-title>{{ period.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-col>
</v-row>
</v-layout>
</v-menu>
</v-toolbar>
<v-card-text>
<apexchart
v-if="statistics"
type="area"
height="350"
:options="options"
:series="statistics.series"
/>
</v-card-text>
</v-card>
</template>
<script>
import VueApexCharts from 'vue-apexcharts'
import statistics from '@/api/statistics'
import DateField from './DateField'
export default {
components: {
apexchart: VueApexCharts,
DateField
},
props: ['domain', 'graphicSet', 'graphicName'],
computed: {
title () {
if (this.statistics) {
return this.statistics.title
}
return ''
},
colors () {
if (this.graphicName === 'averagetraffic') {
return ['#ff6347', '#ffff00', '#4682B4', '#7cfc00', '#ffa500', '#d3d3d3']
}
return ['#ffa500', '#41d1cc']
}
},
data () {
return {
dateMenu: false,
menu: false,
options: {
colors: this.colors,
chart: {
type: 'area',
stacked: false,
zoom: {
type: 'x',
enabled: true,
autoScaleYaxis: true
},
toolbar: {
autoSelected: 'zoom'
}
},
dataLabels: {
enabled: false
},
yaxis: {
},
xaxis: {
type: 'datetime'
}
},
end: null,
start: null,
statistics: null,
period: 'day',
periods: [
{ value: 'day', name: this.$gettext('Day') },
{ value: 'week', name: this.$gettext('Week') },
{ value: 'month', name: this.$gettext('Month') },
{ value: 'year', name: this.$gettext('Year') }
]
}
},
methods: {
setCustomPeriod () {
this.period = 'custom'
this.fetchStatistics()
},
setPeriod (period) {
if (period !== this.period) {
this.period = period
this.fetchStatistics()
}
},
fetchStatistics () {
const args = {
graphSet: this.graphicSet,
period: this.period,
graphicName: this.graphicName,
searchQuery: this.domain.name
}
if (this.period === 'custom') {
args.start = this.start
args.end = this.end
}
statistics.getStatistics(args).then(resp => {
this.statistics = resp.data.graphs[this.graphicName]
})
}
},
mounted () {
this.fetchStatistics()
}
}
</script>
<style scoped>
.v-card {
z-index: 5;
}
.v-menu__content {
background-color: white !important;
}
</style>
+17 -2
View File
@@ -11,11 +11,22 @@
<v-row>
<v-col cols="6">
<domain-summary :domain="domain" />
<div class="mt-4" />
</v-col>
<v-col cols="6">
<domain-dns-summary :domain="domain" />
</v-col>
</v-row>
</v-layout>
<v-layout>
<v-row>
<v-col cols="12">
<time-serie-chart :domain="domain" graphic-set="mailtraffic" graphic-name="averagetraffic" />
</v-col>
<v-col cols="12">
<time-serie-chart :domain="domain" graphic-set="mailtraffic" graphic-name="averagetrafficsize" />
</v-col>
</v-row>
</v-layout>
<v-dialog v-model="showEditForm"
persistent
max-width="800px"
@@ -40,13 +51,15 @@ import AdministratorList from '@/components/domains/AdministratorList'
import DomainForm from '@/components/domains/DomainForm'
import DomainDNSSummary from '@/components/domains/DomainDNSSummary'
import DomainSummary from '@/components/domains/DomainSummary'
import TimeSerieChart from '@/components/tools/TimeSerieChart'
export default {
components: {
AdministratorList,
DomainForm,
'domain-dns-summary': DomainDNSSummary,
DomainSummary
DomainSummary,
TimeSerieChart
},
data () {
return {
@@ -55,6 +68,8 @@ export default {
showAdminList: false
}
},
methods: {
},
mounted () {
domains.getDomain(this.$route.params.id).then(resp => {
this.domain = resp.data
+2 -1
View File
@@ -12,7 +12,8 @@
<v-menu offset-y>
<template v-slot:activator="{ on, attrs }">
<v-btn color="primary" v-bind="attrs" v-on="on">
<v-icon left>mdi-plus</v-icon> <translate>New</translate>
<v-icon left>mdi-plus</v-icon>
<translate>New</translate>
</v-btn>
</template>
<v-list dense>
+72
View File
@@ -1954,6 +1954,18 @@ anymatch@~3.1.1:
normalize-path "^3.0.0"
picomatch "^2.0.4"
apexcharts@^3.25.0:
version "3.25.0"
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.25.0.tgz#f3f0f9f344f997230f5c7f2918408aa072627496"
integrity sha512-uM7OF+jLL4ba79noYcrMwMgJW8DI+Ff28CCQoGq23g25z8nGSQEoU+u12YWlECA9gBA5tbmdaQhMxjlK+M6B9Q==
dependencies:
svg.draggable.js "^2.2.2"
svg.easing.js "^2.0.0"
svg.filter.js "^2.0.2"
svg.pathmorphing.js "^0.1.3"
svg.resize.js "^1.4.3"
svg.select.js "^3.0.1"
aproba@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
@@ -9062,6 +9074,61 @@ svg-tags@^1.0.0:
resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=
svg.draggable.js@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz#c514a2f1405efb6f0263e7958f5b68fce50603ba"
integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==
dependencies:
svg.js "^2.0.1"
svg.easing.js@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/svg.easing.js/-/svg.easing.js-2.0.0.tgz#8aa9946b0a8e27857a5c40a10eba4091e5691f12"
integrity sha1-iqmUawqOJ4V6XEChDrpAkeVpHxI=
dependencies:
svg.js ">=2.3.x"
svg.filter.js@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/svg.filter.js/-/svg.filter.js-2.0.2.tgz#91008e151389dd9230779fcbe6e2c9a362d1c203"
integrity sha1-kQCOFROJ3ZIwd5/L5uLJo2LRwgM=
dependencies:
svg.js "^2.2.5"
svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5:
version "2.7.1"
resolved "https://registry.yarnpkg.com/svg.js/-/svg.js-2.7.1.tgz#eb977ed4737001eab859949b4a398ee1bb79948d"
integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==
svg.pathmorphing.js@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz#c25718a1cc7c36e852ecabc380e758ac09bb2b65"
integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==
dependencies:
svg.js "^2.4.0"
svg.resize.js@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/svg.resize.js/-/svg.resize.js-1.4.3.tgz#885abd248e0cd205b36b973c4b578b9a36f23332"
integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==
dependencies:
svg.js "^2.6.5"
svg.select.js "^2.1.2"
svg.select.js@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-2.1.2.tgz#e41ce13b1acff43a7441f9f8be87a2319c87be73"
integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==
dependencies:
svg.js "^2.2.5"
svg.select.js@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-3.0.1.tgz#a4198e359f3825739226415f82176a90ea5cc917"
integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==
dependencies:
svg.js "^2.6.5"
svgo@^1.0.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
@@ -9627,6 +9694,11 @@ void-elements@^3.1.0:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
integrity sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=
vue-apexcharts@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/vue-apexcharts/-/vue-apexcharts-1.6.0.tgz#c7d3afd93f712433d404e5ceeb4e3aa65f422af2"
integrity sha512-sT6tuVTLBwfH3TA7azecDNS/W70bmz14ZJI7aE7QIqcG9I6OywyH7x3hcOeY1v1DxttI8Svc5RuYj4Dd+A5F4g==
vue-cli-plugin-axios@~0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/vue-cli-plugin-axios/-/vue-cli-plugin-axios-0.0.4.tgz#29d4eb48275c7fe15b92e1fd5d95fbe2a966436f"
View File
View File
+64
View File
@@ -0,0 +1,64 @@
"""App. related serializers."""
from django.utils.translation import ugettext as _
from rest_framework import serializers
PERIODS = [
("day", "Day"),
("week", "Week"),
("month", "Month"),
("year", "Year"),
("custom", "Custom")
]
class StatisticsInputSerializer(serializers.Serializer):
"""Serializer used to filter statistics."""
gset = serializers.CharField()
period = serializers.ChoiceField(choices=PERIODS)
graphic = serializers.CharField(required=False)
searchquery = serializers.CharField(required=False)
start = serializers.DateField(required=False)
end = serializers.DateField(required=False)
def validate(self, data):
condition = (
data["period"] == "custom" and
(not data.get("start") or not data.get("end"))
)
if condition:
raise serializers.ValidationError(
_("You must provide start and end dates when period is custom")
)
return data
class GraphPointSerializer(serializers.Serializer):
"""A serializer to represent a point in a curve."""
x = serializers.FloatField()
y = serializers.FloatField()
class GraphCurveSerializer(serializers.Serializer):
"""A serializer to represent a curve in a graph."""
name = serializers.CharField()
backgroundColor = serializers.CharField()
data = GraphPointSerializer(many=True)
class GraphSerializer(serializers.Serializer):
"""A serializer to represent a graph."""
title = serializers.CharField()
series = GraphCurveSerializer(many=True)
class StatisticsSerializer(serializers.Serializer):
"""Serializer to return statistics."""
graphs = GraphSerializer(many=True)
+11
View File
@@ -0,0 +1,11 @@
"""App related API urls."""
from rest_framework import routers
from . import viewsets
router = routers.SimpleRouter()
router.register(
r"statistics", viewsets.StatisticsViewSet, basename="statistics")
urlpatterns = router.urls
+45
View File
@@ -0,0 +1,45 @@
"""App. related viewsets."""
import time
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, response, viewsets
from ... import signals
from . import serializers
class StatisticsViewSet(viewsets.ViewSet):
"""A viewset to provide extra route related to mail statistics."""
permission_classes = (permissions.IsAuthenticated, )
@extend_schema(
parameters=[serializers.StatisticsInputSerializer],
responses={200: serializers.StatisticsSerializer}
)
def list(self, request, **kwargs):
serializer = serializers.StatisticsInputSerializer(data=request.GET)
serializer.is_valid(raise_exception=True)
graph_sets = {}
for result in signals.get_graph_sets.send(
sender="index", user=request.user):
graph_sets.update(result[1])
gset = serializer.validated_data["gset"]
fname = graph_sets[gset].get_file_name(
request.user, serializer.validated_data.get("searchquery"))
period = serializer.validated_data["period"]
if period == "custom":
start = int(
time.mktime(serializer.validated_data["start"].timetuple())
)
end = int(
time.mktime(serializer.validated_data["end"].timetuple())
)
else:
end = int(time.mktime(time.localtime()))
start = "-1{}".format(period)
graphs = graph_sets[gset].export(
fname, start, end, serializer.validated_data.get("graphic")
)
return response.Response({"graphs": graphs})
+22 -27
View File
@@ -1,12 +1,11 @@
"""Classes to define graphics."""
import datetime
import json
import inspect
from itertools import chain
import os
from lxml import etree
import six
from django.conf import settings
from django.utils.encoding import smart_bytes, smart_text
from django.utils.translation import ugettext as _, ugettext_lazy
@@ -18,7 +17,6 @@ from modoboa.parameters import tools as param_tools
class Curve:
"""Graphic curve.
Simple way to represent a graphic curve.
@@ -96,36 +94,33 @@ class Graphic:
return smart_text(dpath)
def export(self, rrdfile, start, end):
"""Export data to XML using rrdtool and convert it to JSON."""
"""Export data to JSON using rrdtool."""
result = []
cmdargs = []
for curve in self._curves:
result += [{
"name": str(curve.legend),
"color": curve.color, "data": []
"backgroundColor": curve.color, "data": []
}]
cmdargs += curve.to_rrd_command_args(rrdfile)
code = 0
cmd = "{} xport --start {} --end {} ".format(
cmd = "{} xport --json -t --start {} --end {} ".format(
self.rrdtool_binary, str(start), str(end))
cmd += " ".join(cmdargs)
code, output = exec_cmd(smart_bytes(cmd))
if code:
return []
tree = etree.fromstring(output)
timestamp = int(tree.xpath('/xport/meta/start')[0].text)
step = int(tree.xpath('/xport/meta/step')[0].text)
for row in tree.xpath('/xport/data/row'):
for vindex, value in enumerate(row.findall('v')):
if value.text == 'NaN':
result[vindex]['data'].append({'x': timestamp, 'y': 0})
else:
result[vindex]['data'].append(
{'x': timestamp, 'y': float(value.text)}
)
timestamp += step
xport = json.loads(output)
for row in xport['data']:
timestamp = int(row[0])
date = datetime.datetime.fromtimestamp(timestamp).isoformat(sep=' ')
for (vindex, value) in enumerate(row[1:]):
result[vindex]['data'].append(
{'x': date, 'y': value, 'timestamp': timestamp}
)
return result
@@ -158,13 +153,14 @@ class GraphicSet(object):
"""Return database file name."""
return self.file_name
def export(self, rrdfile, start, end):
def export(self, rrdfile, start, end, graphic=None):
result = {}
for graph in self.graphics:
result[graph.display_name] = {
"title": six.text_type(graph.title),
"curves": graph.export(rrdfile, start, end)
}
if graphic is None or graphic == graph.display_name:
result[graph.display_name] = {
"title": graph.title,
"series": graph.export(rrdfile, start, end)
}
return result
@@ -246,10 +242,9 @@ class MailTraffic(GraphicSet):
raise exceptions.PermDeniedException
return results[0].name
def get_file_name(self, request):
def get_file_name(self, user, searchq):
"""Retrieve file name according to user and args."""
searchq = request.GET.get("searchquery", None)
return self._check_domain_access(request.user, searchq)
return self._check_domain_access(user, searchq)
class AccountCreationGraphic(Graphic):
+1
View File
@@ -21,6 +21,7 @@ urlpatterns = [
path('', include("modoboa.limits.urls_api")),
path('', include("modoboa.relaydomains.urls_api")),
path('', include("modoboa.dnstools.api.v2.urls")),
path('', include("modoboa.maillog.api.v2.urls")),
]
urlpatterns += exts_pool.get_urls(category="api")