mirror of
https://github.com/mail-in-a-box/mailinabox.git
synced 2026-03-05 15:57:23 +01:00
Initial commit of a log capture and reporting feature
This adds a new section to the admin panel called "Activity", that supplies charts, graphs and details about messages entering and leaving the host. A new daemon captures details of system mail activity by monitoring the /var/log/mail.log file, summarizing it into a sqllite database that's kept in user-data.
This commit is contained in:
95
management/reporting/ui/capture-db-stats.js
Normal file
95
management/reporting/ui/capture-db-stats.js
Normal file
@@ -0,0 +1,95 @@
|
||||
|
||||
|
||||
Vue.component('capture-db-stats', {
|
||||
props: {
|
||||
},
|
||||
|
||||
template:'<div>'+
|
||||
'<template v-if="stats">'+
|
||||
'<caption class="text-nowrap">Database date range</caption><div class="ml-2">First: {{stats.mta_connect.connect_time.min_str}}</div><div class="ml-2">Last: {{stats.mta_connect.connect_time.max_str}}</div>'+
|
||||
'<div class="mt-2">'+
|
||||
' <b-table-lite small caption="Connections by disposition" caption-top :fields="row_counts.fields" :items=row_counts.items></b-table-lite>'+
|
||||
'</div>'+
|
||||
'</template>'+
|
||||
'<spinner v-else></spinner>'+
|
||||
'</div>'
|
||||
,
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
stats: null,
|
||||
stats_time: null,
|
||||
row_counts: {}
|
||||
};
|
||||
},
|
||||
|
||||
created: function() {
|
||||
this.getStats();
|
||||
},
|
||||
|
||||
methods: {
|
||||
getStats: function() {
|
||||
axios.get('/reports/capture/db/stats')
|
||||
.then(response => {
|
||||
this.stats = response.data;
|
||||
this.stats_time = Date.now();
|
||||
|
||||
// convert dates
|
||||
var parser = d3.utcParse(this.stats.date_parse_format);
|
||||
[ 'min', 'max' ].forEach( k => {
|
||||
var d = parser(this.stats.mta_connect.connect_time[k]);
|
||||
this.stats.mta_connect.connect_time[k] = d;
|
||||
this.stats.mta_connect.connect_time[k+'_str'] =
|
||||
d==null ? '-' : DateFormatter.dt_long(d);
|
||||
});
|
||||
|
||||
// make a small bvTable of row counts
|
||||
this.row_counts = {
|
||||
items: [],
|
||||
fields: [ 'name', 'count', 'percent' ],
|
||||
field_types: [
|
||||
{ type:'text/plain', label:'Disposition' },
|
||||
'number/plain',
|
||||
{ type: 'number/percent', label:'Pct', places:1 },
|
||||
],
|
||||
};
|
||||
BvTable.setFieldDefinitions(
|
||||
this.row_counts.fields,
|
||||
this.row_counts.field_types
|
||||
);
|
||||
this.row_counts.fields[0].formatter = (v, key, item) => {
|
||||
return new ConnectionDisposition(v).short_desc
|
||||
};
|
||||
this.row_counts.fields[0].tdClass = 'text-capitalize';
|
||||
|
||||
|
||||
const total = this.stats.mta_connect.count;
|
||||
for (var name in this.stats.mta_connect.disposition)
|
||||
{
|
||||
const count =
|
||||
this.stats.mta_connect.disposition[name].count;
|
||||
this.row_counts.items.push({
|
||||
name: name,
|
||||
count: count,
|
||||
percent: count / total
|
||||
});
|
||||
}
|
||||
this.row_counts.items.sort((a,b) => {
|
||||
return a.count > b.count ? -1 :
|
||||
a.count < b.count ? 1 : 0;
|
||||
})
|
||||
this.row_counts.items.push({
|
||||
name:'Total',
|
||||
count:this.stats.mta_connect.count,
|
||||
percent:1,
|
||||
'_rowVariant': 'primary'
|
||||
});
|
||||
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
this.$root.handleError(error);
|
||||
});
|
||||
},
|
||||
}
|
||||
});
|
||||
206
management/reporting/ui/chart-multi-line-timeseries.js
Normal file
206
management/reporting/ui/chart-multi-line-timeseries.js
Normal file
@@ -0,0 +1,206 @@
|
||||
Vue.component('chart-multi-line-timeseries', {
|
||||
props: {
|
||||
chart_data: { type:Object, required:false }, /* TimeseriesData */
|
||||
width: { type:Number, default: ChartPrefs.default_width },
|
||||
height: { type:Number, default: ChartPrefs.default_height },
|
||||
},
|
||||
|
||||
render: function(ce) {
|
||||
return ChartVue.create_svg(ce, [0, 0, this.width, this.height]);
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
tsdata: this.chart_data,
|
||||
margin: {
|
||||
top: ChartPrefs.axis_font_size,
|
||||
bottom: ChartPrefs.axis_font_size * 2,
|
||||
left: ChartPrefs.axis_font_size *3,
|
||||
right: ChartPrefs.axis_font_size
|
||||
},
|
||||
xscale: null,
|
||||
yscale: null,
|
||||
colors: ChartPrefs.line_colors
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
'chart_data': function(newval) {
|
||||
this.tsdata = newval;
|
||||
this.draw();
|
||||
}
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
this.draw();
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
draw: function() {
|
||||
if (! this.tsdata) {
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = d3.select(this.$el);
|
||||
svg.selectAll("g").remove();
|
||||
|
||||
if (this.tsdata.dates.length == 0) {
|
||||
// no data ...
|
||||
svg.append("g")
|
||||
.append("text")
|
||||
.attr("font-family", ChartPrefs.default_font_family)
|
||||
.attr("font-size", ChartPrefs.label_font_size)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("x", this.width/2)
|
||||
.attr("y", this.height/2)
|
||||
.text("no data");
|
||||
}
|
||||
|
||||
this.xscale = d3.scaleUtc()
|
||||
.domain(d3.extent(this.tsdata.dates))
|
||||
.nice()
|
||||
.range([this.margin.left, this.width - this.margin.right])
|
||||
|
||||
this.yscale = d3.scaleLinear()
|
||||
.domain([
|
||||
d3.min(this.tsdata.series, d => d3.min(d.values)),
|
||||
d3.max(this.tsdata.series, d => d3.max(d.values))
|
||||
])
|
||||
.nice()
|
||||
.range([this.height - this.margin.bottom, this.margin.top])
|
||||
|
||||
svg.append("g")
|
||||
.call(this.xAxis.bind(this))
|
||||
.attr("font-size", ChartPrefs.axis_font_size);
|
||||
|
||||
svg.append("g")
|
||||
.call(this.yAxis.bind(this))
|
||||
.attr("font-size", ChartPrefs.axis_font_size);
|
||||
|
||||
const line = d3.line()
|
||||
.defined(d => !isNaN(d))
|
||||
.x((d, i) => this.xscale(this.tsdata.dates[i]))
|
||||
.y(d => this.yscale(d));
|
||||
|
||||
const path = svg.append("g")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("stroke-linejoin", "round")
|
||||
.attr("stroke-linecap", "round")
|
||||
.selectAll("path")
|
||||
.data(this.tsdata.series)
|
||||
.join("path")
|
||||
.style("mix-blend-mode", "multiply")
|
||||
.style("stroke", (d, i) => this.colors[i])
|
||||
.attr("d", d => line(d.values))
|
||||
;
|
||||
|
||||
svg.call(this.hover.bind(this), path);
|
||||
},
|
||||
|
||||
xAxis: function(g) {
|
||||
var x = g.attr(
|
||||
'transform',
|
||||
`translate(0, ${this.height - this.margin.bottom})`
|
||||
).call(
|
||||
d3.axisBottom(this.xscale)
|
||||
.ticks(this.width / 80)
|
||||
.tickSizeOuter(0)
|
||||
);
|
||||
return x;
|
||||
},
|
||||
|
||||
yAxis: function(g) {
|
||||
var y = g.attr(
|
||||
"transform",
|
||||
`translate(${this.margin.left},0)`
|
||||
).call(
|
||||
d3.axisLeft(this.yscale)
|
||||
.ticks(this.height/50)
|
||||
).call(
|
||||
g => g.select(".domain").remove()
|
||||
).call(
|
||||
g => ChartVue.add_yAxisLegend(g, this.tsdata, this.colors)
|
||||
);
|
||||
return y;
|
||||
},
|
||||
|
||||
hover: function(svg, path) {
|
||||
if ("ontouchstart" in document) svg
|
||||
.style("-webkit-tap-highlight-color", "transparent")
|
||||
.on("touchmove", moved.bind(this))
|
||||
.on("touchstart", entered)
|
||||
.on("touchend", left)
|
||||
else svg
|
||||
.on("mousemove", moved.bind(this))
|
||||
.on("mouseenter", entered)
|
||||
.on("mouseleave", left);
|
||||
|
||||
const dot = svg.append("g")
|
||||
.attr("display", "none");
|
||||
|
||||
dot.append("circle")
|
||||
.attr("r", 2.5);
|
||||
|
||||
dot.append("text")
|
||||
.attr("font-family", ChartPrefs.default_font_family)
|
||||
.attr("font-size", ChartPrefs.default_font_size)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", -8);
|
||||
|
||||
function moved(event) {
|
||||
if (!event) event = d3.event;
|
||||
event.preventDefault();
|
||||
var pointer;
|
||||
if (d3.pointer)
|
||||
pointer = d3.pointer(event, svg.node());
|
||||
else
|
||||
pointer = d3.mouse(svg.node());
|
||||
const xvalue = this.xscale.invert(pointer[0]); // date
|
||||
const yvalue = this.yscale.invert(pointer[1]); // number
|
||||
//const i = d3.bisectCenter(this.tsdata.dates, xvalue); // index
|
||||
const i = d3.bisect(this.tsdata.dates, xvalue); // index
|
||||
if (i >= this.tsdata.dates.length) return;
|
||||
// closest series
|
||||
var closest = null;
|
||||
for (var sidx=0; sidx<this.tsdata.series.length; sidx++) {
|
||||
var v = Math.abs(this.tsdata.series[sidx].values[i] - yvalue);
|
||||
if (closest === null || v<closest.val) {
|
||||
closest = {
|
||||
sidx: sidx,
|
||||
val: v
|
||||
};
|
||||
}
|
||||
}
|
||||
const s = this.tsdata.series[closest.sidx];
|
||||
if (i>= s.values.length) {
|
||||
dot.attr("display", "none");
|
||||
return;
|
||||
}
|
||||
else {
|
||||
dot.attr("display", null);
|
||||
path.attr("stroke", d => d === s ? null : "#ddd")
|
||||
.filter(d => d === s).raise();
|
||||
dot.attr(
|
||||
"transform",
|
||||
`translate(${this.xscale(this.tsdata.dates[i])},${this.yscale(s.values[i])})`
|
||||
);
|
||||
dot.select("text").text(`${this.tsdata.formatDateTimeShort(this.tsdata.dates[i])} (${NumberFormatter.format(s.values[i])})`);
|
||||
}
|
||||
}
|
||||
|
||||
function entered() {
|
||||
path.style("mix-blend-mode", null).attr("stroke", "#ddd");
|
||||
dot.attr("display", null);
|
||||
}
|
||||
|
||||
function left() {
|
||||
path.style("mix-blend-mode", "multiply").attr("stroke", null);
|
||||
dot.attr("display", "none");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
165
management/reporting/ui/chart-pie.js
Normal file
165
management/reporting/ui/chart-pie.js
Normal file
@@ -0,0 +1,165 @@
|
||||
Vue.component('chart-pie', {
|
||||
/*
|
||||
* chart_data: [
|
||||
* { name: 'name', value: value },
|
||||
* ...
|
||||
* ]
|
||||
*
|
||||
* if prop `labels` is false, a legend is shown instead of
|
||||
* labeling each pie slice
|
||||
*/
|
||||
props: {
|
||||
chart_data: Array,
|
||||
formatter: { type: Function, default: NumberFormatter.format },
|
||||
name_formatter: Function,
|
||||
labels: { type:Boolean, default: true },
|
||||
width: { type:Number, default: ChartPrefs.default_width },
|
||||
height: { type:Number, default: ChartPrefs.default_height },
|
||||
},
|
||||
|
||||
render: function(ce) {
|
||||
var svg = ChartVue.create_svg(ce, [
|
||||
-this.width/2, -this.height/2, this.width, this.height
|
||||
]);
|
||||
if (this.labels) {
|
||||
return svg;
|
||||
}
|
||||
|
||||
/*
|
||||
<div class="ml-1">
|
||||
<div v-for="d in legend">
|
||||
<span class="d-inline-block text-right pr-1 rounded" :style="{width:'5em','background-color':d.color}">{{d.value_str}}</span> {{d.name}}
|
||||
</div>
|
||||
</div>
|
||||
*/
|
||||
var legend_children = [];
|
||||
this.legend.forEach(d => {
|
||||
var span = ce('span', { attrs: {
|
||||
'class': 'd-inline-block text-right pr-1 mr-1 rounded',
|
||||
'style': `width:5em; background-color:${d.color}`
|
||||
}}, this.formatter(d.value));
|
||||
|
||||
legend_children.push(ce('div', [ span, d.name ]));
|
||||
});
|
||||
|
||||
var div_legend = ce('div', { attrs: {
|
||||
'class': 'ml-1 mt-2'
|
||||
}}, legend_children);
|
||||
|
||||
return ce('div', { attrs: {
|
||||
'class': "d-flex align-items-start"
|
||||
}}, [ svg, div_legend ]);
|
||||
|
||||
},
|
||||
|
||||
computed: {
|
||||
legend: function() {
|
||||
if (this.labels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var legend = [];
|
||||
if (this.chart_data) {
|
||||
this.chart_data.forEach((d,i) => {
|
||||
legend.push({
|
||||
name: this.name_formatter ?
|
||||
this.name_formatter(d.name) : d.name,
|
||||
value: d.value,
|
||||
color: this.colors[i % this.colors.length]
|
||||
});
|
||||
});
|
||||
}
|
||||
legend.sort((a,b) => {
|
||||
return a.value > b.value ? -11 :
|
||||
a.value < b.value ? 1 : 0;
|
||||
});
|
||||
return legend;
|
||||
}
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
chdata: this.chart_data,
|
||||
colors: this.colors || ChartPrefs.colors,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
'chart_data': function(newval) {
|
||||
this.chdata = newval;
|
||||
this.draw();
|
||||
}
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
this.draw();
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
draw: function() {
|
||||
if (! this.chdata) return;
|
||||
|
||||
var svg = d3.select(this.$el);
|
||||
if (! this.labels) svg = svg.select('svg');
|
||||
svg.selectAll("g").remove();
|
||||
|
||||
var chdata = this.chdata;
|
||||
var nodata = false;
|
||||
if (d3.sum(this.chdata, d => d.value) == 0) {
|
||||
// no data
|
||||
chdata = [{ name:'no data', value:100 }]
|
||||
nodata = true;
|
||||
}
|
||||
|
||||
const pie = d3.pie().sort(null).value(d => d.value);
|
||||
const arcs = pie(chdata);
|
||||
const arc = d3.arc()
|
||||
.innerRadius(0)
|
||||
.outerRadius(Math.min(this.width, this.height) / 2 - 1);
|
||||
|
||||
var radius = Math.min(this.width, this.height) / 2;
|
||||
if (chdata.length == 1)
|
||||
radius *= 0.1;
|
||||
else if (chdata.length <= 3)
|
||||
radius *= 0.65;
|
||||
else if (chdata.length <= 6)
|
||||
radius *= 0.7;
|
||||
else
|
||||
radius *= 0.8;
|
||||
arcLabel = d3.arc().innerRadius(radius).outerRadius(radius);
|
||||
|
||||
svg.append("g")
|
||||
.attr("stroke", "white")
|
||||
.selectAll("path")
|
||||
.data(arcs)
|
||||
.join("path")
|
||||
.attr("fill", (d,i) => this.colors[i % this.colors.length])
|
||||
.attr("d", arc)
|
||||
.append("title")
|
||||
.text(d => `${d.data.name}: ${this.formatter(d.data.value)}`);
|
||||
|
||||
if (this.labels) {
|
||||
svg.append("g")
|
||||
.attr("font-family", ChartPrefs.default_font_family)
|
||||
.attr("font-size", ChartPrefs.label_font_size)
|
||||
.attr("text-anchor", "middle")
|
||||
.selectAll("text")
|
||||
.data(arcs)
|
||||
.join("text")
|
||||
.attr("transform", d => `translate(${arcLabel.centroid(d)})`)
|
||||
.call(text => text.append("tspan")
|
||||
.attr("y", "-0.4em")
|
||||
.attr("font-weight", "bold")
|
||||
.text(d => d.data.name))
|
||||
.call(text => text.filter(d => (d.endAngle - d.startAngle) > 0.25).append("tspan")
|
||||
.attr("x", 0)
|
||||
.attr("y", "0.7em")
|
||||
.attr("fill-opacity", 0.7)
|
||||
.text(d => nodata ? null : this.formatter(d.data.value)));
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
225
management/reporting/ui/chart-stacked-bar-timeseries.js
Normal file
225
management/reporting/ui/chart-stacked-bar-timeseries.js
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
stacked bar chart
|
||||
*/
|
||||
|
||||
Vue.component('chart-stacked-bar-timeseries', {
|
||||
props: {
|
||||
chart_data: { type:Object, required:false }, /* TimeseriesData */
|
||||
width: { type:Number, default: ChartPrefs.default_width },
|
||||
height: { type:Number, default: ChartPrefs.default_height },
|
||||
},
|
||||
|
||||
render: function(ce) {
|
||||
return ChartVue.create_svg(ce, [0, 0, this.width, this.height]);
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
tsdata: null,
|
||||
stacked: null,
|
||||
margin: {
|
||||
top: ChartPrefs.axis_font_size,
|
||||
bottom: ChartPrefs.axis_font_size*2,
|
||||
left: ChartPrefs.axis_font_size*3,
|
||||
right: ChartPrefs.axis_font_size
|
||||
},
|
||||
xscale: null,
|
||||
yscale: null,
|
||||
colors: ChartPrefs.colors, /* array of colors */
|
||||
};
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
if (this.chart_data) {
|
||||
this.stack(this.chart_data);
|
||||
this.draw();
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
'chart_data': function(newv, oldv) {
|
||||
this.stack(newv);
|
||||
this.draw();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
stack: function(data) {
|
||||
/* "stack" the data using d3.stack() */
|
||||
// 1. reorganize into the format stack() wants -- an
|
||||
// array of objects, with each object having a key of
|
||||
// 'date', plus one for each series
|
||||
var stacker_input = data.dates.map((d, i) => {
|
||||
var array_el = { date: d }
|
||||
data.series.forEach(s => {
|
||||
array_el[s.name] = s.values[i];
|
||||
})
|
||||
return array_el;
|
||||
});
|
||||
|
||||
// 2. call d3.stack() to get the stacking function, which
|
||||
// creates yet another version of the series data,
|
||||
// reformatted for more easily creating stacked bars.
|
||||
//
|
||||
// It returns a new array (see the d3 docs):
|
||||
// [
|
||||
// [ /* series 1 */
|
||||
// [ Number, Number, data: { date: Date } ],
|
||||
// [ ... ], ...
|
||||
// ],
|
||||
// [ /* series 2 */
|
||||
// [ Number, Number, data: { date: Date } ],
|
||||
// [ ... ], ...
|
||||
// ],
|
||||
// ...
|
||||
// ]
|
||||
//
|
||||
var stacker = d3.stack()
|
||||
.keys(data.series.map(s => s.name))
|
||||
.order(d3.stackOrderNone)
|
||||
.offset(d3.stackOffsetNone);
|
||||
|
||||
// 3. store the data
|
||||
this.tsdata = data;
|
||||
this.stacked = stacker(stacker_input);
|
||||
},
|
||||
|
||||
|
||||
draw: function() {
|
||||
const svg = d3.select(this.$el);
|
||||
svg.selectAll("g").remove();
|
||||
|
||||
if (this.tsdata.dates.length == 0) {
|
||||
// no data ...
|
||||
svg.append("g")
|
||||
.append("text")
|
||||
.attr("font-family", ChartPrefs.default_font_family)
|
||||
.attr("font-size", ChartPrefs.label_font_size)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("x", this.width/2)
|
||||
.attr("y", this.height/2)
|
||||
.text("no data");
|
||||
}
|
||||
|
||||
this.xscale = d3.scaleUtc()
|
||||
.domain(d3.extent(this.tsdata.dates))
|
||||
.nice()
|
||||
.range([this.margin.left, this.width - this.margin.right])
|
||||
|
||||
var barwidth = this.tsdata.barwidth(this.xscale, 1);
|
||||
var padding = barwidth / 2;
|
||||
|
||||
this.yscale = d3.scaleLinear()
|
||||
.domain([
|
||||
0,
|
||||
d3.sum(this.tsdata.series, s => d3.max(s.values))
|
||||
])
|
||||
.range([
|
||||
this.height - this.margin.bottom,
|
||||
this.margin.top,
|
||||
]);
|
||||
|
||||
svg.append("g")
|
||||
.call(this.xAxis.bind(this, padding))
|
||||
.attr("font-size", ChartPrefs.axis_font_size);
|
||||
|
||||
svg.append("g")
|
||||
.call(this.yAxis.bind(this))
|
||||
.attr("font-size", ChartPrefs.axis_font_size);
|
||||
|
||||
|
||||
for (var s_idx=0; s_idx<this.tsdata.series.length; s_idx++) {
|
||||
svg.append("g")
|
||||
.datum(s_idx)
|
||||
.attr("fill", this.colors[s_idx])
|
||||
.selectAll("rect")
|
||||
.data(this.stacked[s_idx])
|
||||
.join("rect")
|
||||
.attr("x", d => this.xscale(d.data.date) - barwidth/2 + padding)
|
||||
.attr("y", d => this.yscale(d[1]))
|
||||
.attr("height", d => this.yscale(d[0]) - this.yscale(d[1]))
|
||||
.attr("width", barwidth)
|
||||
.call( hover.bind(this) )
|
||||
|
||||
// .append("title")
|
||||
// .text(d => `${this.tsdata.series[s_idx].name}: ${NumberFormatter.format(d.data[this.tsdata.series[s_idx].name])}`)
|
||||
;
|
||||
}
|
||||
|
||||
var hovinfo = svg.append("g");
|
||||
|
||||
function hover(rect) {
|
||||
if ("ontouchstart" in document) rect
|
||||
.style("-webkit-tap-highlight-color", "transparent")
|
||||
.on("touchstart", entered.bind(this))
|
||||
.on("touchend", left)
|
||||
else rect
|
||||
.on("mouseenter", entered.bind(this))
|
||||
.on("mouseleave", left);
|
||||
|
||||
function entered(event, d) {
|
||||
var rect = d3.select(event.target)
|
||||
.attr("fill", "#ccc");
|
||||
var d = rect.datum();
|
||||
var s_idx = d3.select(rect.node().parentNode).datum();
|
||||
var s_name = this.tsdata.series[s_idx].name;
|
||||
var v = d.data[s_name];
|
||||
var x = Number(rect.attr('x')) + barwidth/2;
|
||||
|
||||
hovinfo.attr(
|
||||
"transform",
|
||||
`translate( ${x}, ${rect.attr('y')} )`)
|
||||
.append('text')
|
||||
.attr("font-family", ChartPrefs.default_font_family)
|
||||
.attr("font-size", ChartPrefs.default_font_size)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", -3)
|
||||
.text(`${this.tsdata.formatDateTimeShort(d.data.date)}`);
|
||||
hovinfo.append("text")
|
||||
.attr("font-family", ChartPrefs.default_font_family)
|
||||
.attr("font-size", ChartPrefs.default_font_size)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", -3 - ChartPrefs.default_font_size)
|
||||
.text(`${s_name} (${NumberFormatter.format(v)})`);
|
||||
|
||||
}
|
||||
|
||||
function left(event) {
|
||||
d3.select(event.target).attr("fill", null);
|
||||
hovinfo.selectAll("text").remove();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
xAxis: function(padding, g) {
|
||||
var x = g.attr(
|
||||
'transform',
|
||||
`translate(${padding}, ${this.height - this.margin.bottom})`
|
||||
).call(
|
||||
d3.axisBottom(this.xscale)
|
||||
.ticks(this.width / 80)
|
||||
.tickSizeOuter(0)
|
||||
);
|
||||
return x;
|
||||
},
|
||||
|
||||
yAxis: function(g) {
|
||||
var y = g.attr(
|
||||
"transform",
|
||||
`translate(${this.margin.left},0)`
|
||||
).call(
|
||||
d3.axisLeft(this.yscale)
|
||||
.ticks(this.height/50)
|
||||
).call(g =>
|
||||
g.select(".domain").remove()
|
||||
).call(g => {
|
||||
ChartVue.add_yAxisLegend(g, this.tsdata, this.colors);
|
||||
});
|
||||
|
||||
return y;
|
||||
},
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
45
management/reporting/ui/chart-table.js
Normal file
45
management/reporting/ui/chart-table.js
Normal file
@@ -0,0 +1,45 @@
|
||||
Vue.component('chart-table', {
|
||||
props: {
|
||||
items: Array,
|
||||
fields: Array,
|
||||
caption: String
|
||||
},
|
||||
|
||||
/* <b-table-lite striped small :fields="fields_x" :items="items" caption-top><template #table-caption><span class="text-nowrap">{{caption}}</span></template></b-table>*/
|
||||
render: function(ce) {
|
||||
var scopedSlots= {
|
||||
'table-caption': props =>
|
||||
ce('span', { class: { 'text-nowrap':true }}, this.caption)
|
||||
};
|
||||
if (this.$scopedSlots) {
|
||||
for (var slotName in this.$scopedSlots) {
|
||||
scopedSlots[slotName] = this.$scopedSlots[slotName];
|
||||
}
|
||||
}
|
||||
var table = ce('b-table-lite', {
|
||||
props: {
|
||||
'striped': true,
|
||||
'small': true,
|
||||
'fields': this.fields_x,
|
||||
'items': this.items,
|
||||
'caption-top': true
|
||||
},
|
||||
scopedSlots: scopedSlots
|
||||
});
|
||||
|
||||
return table;
|
||||
},
|
||||
|
||||
computed: {
|
||||
fields_x: function() {
|
||||
if (this.items.length == 0) {
|
||||
return [{
|
||||
key: 'no data',
|
||||
thClass: 'text-nowrap'
|
||||
}];
|
||||
}
|
||||
return this.fields;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
20
management/reporting/ui/chart.readme.txt
Normal file
20
management/reporting/ui/chart.readme.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
most charts have been adapted from ones at Obserable,
|
||||
which had the following license:
|
||||
|
||||
https://observablehq.com/@d3/multi-line-chart
|
||||
|
||||
Copyright 2018–2020 Observable, Inc.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
972
management/reporting/ui/charting.js
Normal file
972
management/reporting/ui/charting.js
Normal file
@@ -0,0 +1,972 @@
|
||||
|
||||
class ChartPrefs {
|
||||
static get colors() {
|
||||
// see: https://github.com/d3/d3-scale-chromatic
|
||||
return d3.schemeSet2;
|
||||
}
|
||||
|
||||
static get line_colors() {
|
||||
// see: https://github.com/d3/d3-scale-chromatic
|
||||
return d3.schemeCategory10;
|
||||
}
|
||||
|
||||
static get default_width() {
|
||||
return 600;
|
||||
}
|
||||
|
||||
static get default_height() {
|
||||
return 400;
|
||||
}
|
||||
|
||||
static get axis_font_size() {
|
||||
return 12;
|
||||
}
|
||||
|
||||
static get default_font_size() {
|
||||
return 10;
|
||||
}
|
||||
|
||||
static get label_font_size() {
|
||||
return 12;
|
||||
}
|
||||
|
||||
static get default_font_family() {
|
||||
return "sans-serif";
|
||||
}
|
||||
|
||||
static get locales() {
|
||||
return "en";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
class DateFormatter {
|
||||
/*
|
||||
* date and time
|
||||
*/
|
||||
static dt_long(d, options) {
|
||||
let opt = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
Object.assign(opt, options);
|
||||
return d.toLocaleString(ChartPrefs.locales, opt);
|
||||
}
|
||||
static dt_short(d, options) {
|
||||
return d.toLocaleString(ChartPrefs.locales, options);
|
||||
}
|
||||
|
||||
/*
|
||||
* date
|
||||
*/
|
||||
static d_long(d, options) {
|
||||
let opt = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
Object.assign(opt, options);
|
||||
return d.toLocaleDateString(ChartPrefs.locales, opt);
|
||||
}
|
||||
static d_short(d, options) {
|
||||
return d.toLocaleDateString(ChartPrefs.locales, options);
|
||||
}
|
||||
|
||||
/*
|
||||
* time
|
||||
*/
|
||||
static t_long(d, options) {
|
||||
return d.toLocaleTimeString(ChartPrefs.locales, options);
|
||||
}
|
||||
static t_short(d, options) {
|
||||
return d.toLocaleTimeString(ChartPrefs.locales, options);
|
||||
}
|
||||
static t_span(d, unit) {
|
||||
// `d` is milliseconds
|
||||
// `unit` is desired max precision output unit (eg 's')
|
||||
unit = unit || 's';
|
||||
const cvt = [{
|
||||
ms: (24 * 60 * 60 * 1000),
|
||||
ushort: 'd',
|
||||
ulong: 'day'
|
||||
}, {
|
||||
ms: (60 * 60 * 1000),
|
||||
ushort: 'h',
|
||||
ulong: 'hour'
|
||||
}, {
|
||||
ms: (60 * 1000),
|
||||
ushort: 'm',
|
||||
ulong: 'minute'
|
||||
}, {
|
||||
ms: 1000,
|
||||
ushort: 's',
|
||||
ulong: 'second'
|
||||
}, {
|
||||
ms: 1,
|
||||
ushort: 'ms',
|
||||
ulong: 'milliseconds'
|
||||
}];
|
||||
|
||||
var first = false;
|
||||
var remainder = d;
|
||||
var out = [];
|
||||
var done = false;
|
||||
cvt.forEach( c => {
|
||||
if (done) return;
|
||||
var amt = Math.floor( remainder / c.ms );
|
||||
remainder = remainder % c.ms;
|
||||
if (first || amt > 0) {
|
||||
first = true;
|
||||
out.push(amt + c.ushort);
|
||||
}
|
||||
if (unit == c.ushort || unit == c.ulong) {
|
||||
done = true;
|
||||
}
|
||||
});
|
||||
return out.join(' ');
|
||||
}
|
||||
|
||||
/*
|
||||
* universal "YYYY-MM-DD HH:MM:SS" formats
|
||||
*/
|
||||
static ymd(d) {
|
||||
const ye = d.getFullYear();
|
||||
const mo = '0'+(d.getMonth() + 1);
|
||||
const da = '0'+d.getDate();
|
||||
return `${ye}-${mo.substr(mo.length-2)}-${da.substr(da.length-2)}`;
|
||||
}
|
||||
|
||||
static ymd_utc(d) {
|
||||
const ye = d.getUTCFullYear();
|
||||
const mo = '0'+(d.getUTCMonth() + 1);
|
||||
const da = '0'+d.getUTCDate();
|
||||
return `${ye}-${mo.substr(mo.length-2)}-${da.substr(da.length-2)}`;
|
||||
}
|
||||
|
||||
static ymdhms(d) {
|
||||
const ho = '0'+d.getHours();
|
||||
const mi = '0'+d.getMinutes();
|
||||
const se = '0'+d.getSeconds();
|
||||
return `${DateFormatter.ymd(d)} ${ho.substr(ho.length-2)}:${mi.substr(mi.length-2)}:${se.substr(se.length-2)}`;
|
||||
}
|
||||
|
||||
static ymdhms_utc(d) {
|
||||
const ho = '0'+d.getUTCHours();
|
||||
const mi = '0'+d.getUTCMinutes();
|
||||
const se = '0'+d.getUTCSeconds();
|
||||
return `${DateFormatter.ymd_utc(d)} ${ho.substr(ho.length-2)}:${mi.substr(mi.length-2)}:${se.substr(se.length-2)}`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
class DateRange {
|
||||
/*
|
||||
* ranges
|
||||
*/
|
||||
static ytd() {
|
||||
var s = new Date();
|
||||
s.setMonth(0);
|
||||
s.setDate(1);
|
||||
s.setHours(0);
|
||||
s.setMinutes(0);
|
||||
s.setSeconds(0);
|
||||
s.setMilliseconds(0);
|
||||
return [ s, new Date() ];
|
||||
}
|
||||
static ytd_as_ymd() {
|
||||
return DateRange.ytd().map(d => DateFormatter.ymd(d));
|
||||
}
|
||||
|
||||
static mtd() {
|
||||
var s = new Date();
|
||||
s.setDate(1);
|
||||
s.setHours(0);
|
||||
s.setMinutes(0);
|
||||
s.setSeconds(0);
|
||||
s.setMilliseconds(0);
|
||||
return [ s, new Date() ];
|
||||
}
|
||||
static mtd_as_ymd() {
|
||||
return DateRange.mtd().map(d => DateFormatter.ymd(d));
|
||||
}
|
||||
|
||||
static wtd() {
|
||||
var s = new Date();
|
||||
var offset = s.getDay() * (24 * 60 * 60 * 1000);
|
||||
s.setTime(s.getTime() - offset);
|
||||
s.setHours(0);
|
||||
s.setMinutes(0);
|
||||
s.setSeconds(0);
|
||||
s.setMilliseconds(0);
|
||||
return [ s, new Date() ];
|
||||
}
|
||||
static wtd_as_ymd() {
|
||||
return DateRange.wtd().map(d => DateFormatter.ymd(d));
|
||||
}
|
||||
|
||||
static rangeFromType(type) {
|
||||
if (type == 'wtd')
|
||||
return DateRange.wtd();
|
||||
else if (type == 'mtd')
|
||||
return DateRange.mtd();
|
||||
else if (type == 'ytd')
|
||||
return DateRange.ytd();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
class NumberFormatter {
|
||||
static format(v) {
|
||||
return isNaN(v) || v===null ? "N/A" : v.toLocaleString(ChartPrefs.locales);
|
||||
}
|
||||
|
||||
static decimalFormat(v, places, style) {
|
||||
if (isNaN(v) || v===null) return "N/A";
|
||||
if (places === undefined || isNaN(places)) places = 1;
|
||||
if (style === undefined || typeof style != 'string') style = 'decimal';
|
||||
var options = {
|
||||
style: style,
|
||||
minimumFractionDigits: places
|
||||
};
|
||||
v = v.toLocaleString(ChartPrefs.locales, options);
|
||||
return v;
|
||||
}
|
||||
|
||||
static percentFormat(v, places) {
|
||||
if (places === undefined || isNaN(places)) places = 0;
|
||||
return NumberFormatter.decimalFormat(v, places, 'percent');
|
||||
}
|
||||
|
||||
static humanFormat(v, places) {
|
||||
if (isNaN(v) || v===null) return "N/A";
|
||||
if (places === undefined || isNaN(places)) places = 1;
|
||||
const options = {
|
||||
style: 'unit',
|
||||
minimumFractionDigits: places,
|
||||
unit: 'byte'
|
||||
};
|
||||
var xunit = '';
|
||||
const f = Math.pow(10, places);
|
||||
if (v >= NumberFormatter.tb) {
|
||||
v = Math.round(v / NumberFormatter.tb * f) / f;
|
||||
options.unit='terabyte';
|
||||
xunit = 'T';
|
||||
}
|
||||
else if (v >= NumberFormatter.gb) {
|
||||
v = Math.round(v / NumberFormatter.gb * f) / f;
|
||||
options.unit='gigabyte';
|
||||
xunit = 'G';
|
||||
}
|
||||
else if (v >= NumberFormatter.mb) {
|
||||
v = Math.round(v / NumberFormatter.mb * f) / f;
|
||||
options.unit='megabyte';
|
||||
xunit = 'M';
|
||||
}
|
||||
else if (v >= NumberFormatter.kb) {
|
||||
v = Math.round(v / NumberFormatter.kb * f) / f;
|
||||
options.unit='kilobyte';
|
||||
xunit = 'K';
|
||||
}
|
||||
else {
|
||||
options.minimumFractionDigits = 0;
|
||||
places = 0;
|
||||
}
|
||||
try {
|
||||
return v.toLocaleString(ChartPrefs.locales, options);
|
||||
} catch(e) {
|
||||
if (e instanceof RangeError) {
|
||||
// probably "invalid unit"
|
||||
return NumberFormatter.decimalFormat(v, places) + xunit;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// define static constants in NumberFormatter
|
||||
['kb','mb','gb','tb'].forEach((unit,idx) => {
|
||||
Object.defineProperty(NumberFormatter, unit, {
|
||||
value: Math.pow(1024, idx+1),
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
configurable: false
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
class BvTable {
|
||||
constructor(data, opt) {
|
||||
opt = opt || {};
|
||||
Object.assign(this, data);
|
||||
if (!this.items || !this.fields || !this.field_types) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
BvTable.arraysToObjects(this.items, this.fields);
|
||||
BvTable.setFieldDefinitions(this.fields, this.field_types);
|
||||
|
||||
if (opt._showDetails) {
|
||||
// _showDetails must be set to make it reactive
|
||||
this.items.forEach(item => {
|
||||
item._showDetails = false;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
field_index_of(key) {
|
||||
for (var i=0; i<this.fields.length; i++) {
|
||||
if (this.fields[i].key == key) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
get_field(key, only_showing) {
|
||||
var i = this.field_index_of(key);
|
||||
if (i>=0) return this.fields[i];
|
||||
return this.x_fields && !only_showing ? this.x_fields[key] : null;
|
||||
}
|
||||
|
||||
combine_fields(names, name2, formatter) {
|
||||
// combine field(s) `names` into `name2`, then remove
|
||||
// `names`. use `formatter` as the formatter function
|
||||
// for the new combined field.
|
||||
//
|
||||
// if name2 is not given, just remove all `names` fields
|
||||
//
|
||||
// removed fields are placed into this.x_fields array
|
||||
if (typeof names == 'string') names = [ names ]
|
||||
var idx2 = name2 ? this.field_index_of(name2) : -1;
|
||||
if (! this.x_fields) this.x_fields = {};
|
||||
|
||||
names.forEach(name1 => {
|
||||
var idx1 = this.field_index_of(name1);
|
||||
if (idx1 < 0) return;
|
||||
this.x_fields[name1] = this.fields[idx1];
|
||||
this.fields.splice(idx1, 1);
|
||||
if (idx2>idx1) --idx2;
|
||||
});
|
||||
|
||||
if (idx2 < 0) return null;
|
||||
|
||||
this.fields[idx2].formatter = formatter;
|
||||
return this.fields[idx2];
|
||||
}
|
||||
|
||||
|
||||
static arraysToObjects(items, fields) {
|
||||
/*
|
||||
* convert array-of-arrays `items` to an array of objects
|
||||
* suitable for a <b-table> items (rows of the table).
|
||||
*
|
||||
* `items` is modified in-place
|
||||
*
|
||||
* `fields` is an array of strings, which will become the keys
|
||||
* of each new object. the length of each array in `items`
|
||||
* must match the length of `fields` and the indexes must
|
||||
* correspond.
|
||||
*
|
||||
* the primary purpose is to allow the data provider (server)
|
||||
* to send something like:
|
||||
*
|
||||
* { "items": [
|
||||
* [ "alice", 10.6, 200, "top-10" ],
|
||||
* ....
|
||||
* ],
|
||||
* "fields": [ "name", "x", "y", "label" ]
|
||||
* }
|
||||
*
|
||||
* instead of:
|
||||
*
|
||||
* { "items": [
|
||||
* { "name":"a", "x":10.6, "y":200, "label":"top-10" },
|
||||
* ...
|
||||
* ],
|
||||
* "fields": [ "name", "x", "y", "label" ]
|
||||
* }
|
||||
*
|
||||
* which requires much more bandwidth
|
||||
*
|
||||
*/
|
||||
if (items.length > 0 && !Array.isArray(items[0]))
|
||||
{
|
||||
// already converted
|
||||
return;
|
||||
}
|
||||
for (var i=0; i<items.length; i++) {
|
||||
var o = {};
|
||||
fields.forEach((field, idx) => {
|
||||
o[field] = items[i][idx];
|
||||
});
|
||||
items[i] = o;
|
||||
}
|
||||
}
|
||||
|
||||
static setFieldDefinitions(fields, types) {
|
||||
/*
|
||||
* change elements of array `fields` to bootstrap-vue table
|
||||
* field (column) definitions
|
||||
*
|
||||
* `fields` is an array of field names or existing field
|
||||
* definitions to update. `types` is a correponding array
|
||||
* having the type of each field which will cause one or more
|
||||
* of the following properties to be set on each field:
|
||||
* 'tdClass', 'thClass', 'label', and 'formatter'
|
||||
*/
|
||||
for (var i=0; i<fields.length && i<types.length; i++) {
|
||||
var field = fields[i];
|
||||
fields[i] = new BvTableField(field, types[i]);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
class BvTableField {
|
||||
constructor(field, field_type) {
|
||||
// this:
|
||||
// key - required
|
||||
// label
|
||||
// tdClass
|
||||
// thClass
|
||||
// formatter
|
||||
// .. etc (see bootstrap-vue Table component docs)
|
||||
|
||||
if (typeof field == 'string') {
|
||||
this.key = field;
|
||||
}
|
||||
else {
|
||||
Object.assign(this, field);
|
||||
}
|
||||
|
||||
var ft = field_type;
|
||||
var field = this;
|
||||
|
||||
if (typeof ft == 'string') {
|
||||
ft = { type: ft };
|
||||
}
|
||||
|
||||
if (! ft.subtype && ft.type.indexOf('/') >0) {
|
||||
// optional format, eg "text/email"
|
||||
var s = ft.type.split('/');
|
||||
ft.type = s[0];
|
||||
ft.subtype = s.length > 1 ? s[1] : null;
|
||||
}
|
||||
|
||||
if (ft.label !== undefined) {
|
||||
field.label = ft.label;
|
||||
}
|
||||
|
||||
if (ft.type == 'decimal') {
|
||||
Object.assign(ft, {
|
||||
type: 'number',
|
||||
subtype: 'decimal'
|
||||
});
|
||||
}
|
||||
|
||||
if (ft.type == 'text') {
|
||||
// as-is
|
||||
}
|
||||
else if (ft.type == 'number') {
|
||||
if (ft.subtype == 'plain' ||
|
||||
ft.subtype == 'decimal' && isNaN(ft.places)
|
||||
)
|
||||
{
|
||||
Object.assign(
|
||||
field,
|
||||
BvTableField.numberFieldDefinition()
|
||||
);
|
||||
}
|
||||
|
||||
else if (ft.subtype == 'size') {
|
||||
Object.assign(
|
||||
field,
|
||||
BvTableField.sizeFieldDefinition()
|
||||
);
|
||||
}
|
||||
|
||||
else if (ft.subtype == 'decimal') {
|
||||
Object.assign(
|
||||
field,
|
||||
BvTableField.decimalFieldDefinition(ft.places)
|
||||
);
|
||||
}
|
||||
|
||||
else if (ft.subtype == 'percent') {
|
||||
Object.assign(
|
||||
field,
|
||||
BvTableField.percentFieldDefinition(ft.places)
|
||||
);
|
||||
}
|
||||
}
|
||||
else if (ft.type == 'datetime') {
|
||||
Object.assign(
|
||||
field,
|
||||
BvTableField.datetimeFieldDefinition(ft.showas || 'short', ft.format)
|
||||
);
|
||||
}
|
||||
else if (ft.type == 'time' && ft.subtype == 'span') {
|
||||
Object.assign(
|
||||
field,
|
||||
BvTableField.timespanFieldDefinition(ft.unit || 'ms')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
static numberFieldDefinition() {
|
||||
// <b-table> field definition for a localized numeric value.
|
||||
// eg: "5,001". for additional attributes, see:
|
||||
// https://bootstrap-vue.org/docs/components/table#field-definition-reference
|
||||
return {
|
||||
formatter: NumberFormatter.format,
|
||||
tdClass: 'text-right',
|
||||
thClass: 'text-right'
|
||||
};
|
||||
}
|
||||
|
||||
static sizeFieldDefinition(decimal_places) {
|
||||
// <b-table> field definition for a localized numeric value in
|
||||
// human readable format. eg: "5.1K". `decimal_places` is
|
||||
// optional, which defaults to 1
|
||||
return {
|
||||
formatter: value =>
|
||||
NumberFormatter.humanFormat(value, decimal_places),
|
||||
tdClass: 'text-right text-nowrap',
|
||||
thClass: 'text-right'
|
||||
};
|
||||
}
|
||||
|
||||
static datetimeFieldDefinition(variant, format) {
|
||||
// if the formatter is passed string (utc) dates, convert them
|
||||
// to a native Date objects using the format in `format`. eg:
|
||||
// "%Y-%m-%d %H:%M:%S".
|
||||
//
|
||||
// `variant` can be "long" (default) or "short"
|
||||
var parser = (format ? d3.utcParse(format) : null);
|
||||
if (variant === 'short') {
|
||||
return {
|
||||
formatter: v =>
|
||||
DateFormatter.dt_short(parser ? parser(v) : v)
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
formatter: v =>
|
||||
DateFormatter.dt_long(parser ? parser(v) : v)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static timespanFieldDefinition(unit, output_unit) {
|
||||
var factor = 1;
|
||||
if (unit == 's') factor = 1000;
|
||||
return {
|
||||
formatter: v => DateFormatter.t_span(v * factor, output_unit)
|
||||
};
|
||||
}
|
||||
|
||||
static decimalFieldDefinition(decimal_places) {
|
||||
return {
|
||||
formatter: value =>
|
||||
NumberFormatter.decimalFormat(value, decimal_places),
|
||||
tdClass: 'text-right',
|
||||
thClass: 'text-right'
|
||||
};
|
||||
}
|
||||
|
||||
static percentFieldDefinition(decimal_places) {
|
||||
return {
|
||||
formatter: value =>
|
||||
NumberFormatter.percentFormat(value, decimal_places),
|
||||
tdClass: 'text-right',
|
||||
thClass: 'text-right'
|
||||
};
|
||||
}
|
||||
|
||||
add_cls(cls, to_what) {
|
||||
if (Array.isArray(this[to_what])) {
|
||||
this[to_what].push(cls);
|
||||
}
|
||||
else if (this[to_what] !== undefined) {
|
||||
this[to_what] = [ this[to_what], cls ];
|
||||
}
|
||||
else {
|
||||
this[to_what] = cls;
|
||||
}
|
||||
}
|
||||
|
||||
add_tdClass(cls) {
|
||||
this.add_cls(cls, 'tdClass');
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
class MailBvTable extends BvTable {
|
||||
flag(key, fn) {
|
||||
var field = this.get_field(key, true);
|
||||
if (!field) return;
|
||||
field.add_tdClass(fn);
|
||||
}
|
||||
|
||||
flag_fields(tdClass) {
|
||||
// flag on certain cell values by setting _flagged in each
|
||||
// "flagged" item during its tdClass callback function. add
|
||||
// `tdClass` to the rendered value
|
||||
|
||||
tdClass = tdClass || 'text-danger';
|
||||
|
||||
this.flag('accept_status', (v, key, item) => {
|
||||
if (v === 'reject') {
|
||||
item._flagged = true;
|
||||
return tdClass;
|
||||
}
|
||||
});
|
||||
|
||||
this.flag('relay', (v, key, item) => {
|
||||
if (item.delivery_connection == 'untrusted') {
|
||||
item._flagged = true;
|
||||
return tdClass;
|
||||
}
|
||||
});
|
||||
|
||||
this.flag('status', (v, key, item) => {
|
||||
if (v != 'sent') {
|
||||
item._flagged = true;
|
||||
return tdClass;
|
||||
}
|
||||
});
|
||||
|
||||
this.flag('spam_result', (v, key, item) => {
|
||||
if (item.spam_result && v != 'clean') {
|
||||
item._flagged = true;
|
||||
return tdClass;
|
||||
}
|
||||
});
|
||||
|
||||
this.flag('spf_result', (v, key, item) => {
|
||||
if (v == 'Fail' ||v == 'Softfail') {
|
||||
item._flagged = true;
|
||||
return tdClass;
|
||||
}
|
||||
});
|
||||
|
||||
this.flag('dkim_result', (v, key, item) => {
|
||||
if (item.dkim_result && v != 'pass') {
|
||||
item._flagged = true;
|
||||
return tdClass;
|
||||
}
|
||||
});
|
||||
|
||||
this.flag('dmarc_result', (v, key, item) => {
|
||||
if (v == 'fail') {
|
||||
item._flagged = true;
|
||||
return tdClass;
|
||||
}
|
||||
});
|
||||
|
||||
this.flag('postgrey_result', (v, key, item) => {
|
||||
if (item.postgrey_result && v != 'pass') {
|
||||
item._flagged = true;
|
||||
return tdClass;
|
||||
}
|
||||
});
|
||||
|
||||
this.flag('disposition', (v, key, item) => {
|
||||
if (item.disposition != 'ok') {
|
||||
item._flagged = true;
|
||||
return tdClass;
|
||||
}
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
apply_rowVariant_grouping(variant, group_fn) {
|
||||
// there is 1 row for each recipient of a message
|
||||
// - give all rows of the same message the same
|
||||
// color
|
||||
//
|
||||
// variant is a bootstrap variant like "primary"
|
||||
//
|
||||
// group_fn is a callback receiving an item (row of data) and
|
||||
// the item index and should return null if the item is not
|
||||
// showing or return the group value
|
||||
var last_group = -1;
|
||||
var count = 0;
|
||||
for (var idx=0; idx < this.items.length; idx++)
|
||||
{
|
||||
const item = this.items[idx];
|
||||
const group = group_fn(item, idx);
|
||||
if (group === null || group === undefined) continue
|
||||
|
||||
if (group != last_group) {
|
||||
++count;
|
||||
last_group = group;
|
||||
}
|
||||
item._rowVariant = count % 2 == 0 ? variant : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ChartVue {
|
||||
|
||||
static svg_attrs(viewBox) {
|
||||
var attrs = {
|
||||
width: viewBox[2],
|
||||
height: viewBox[3],
|
||||
viewBox: viewBox.join(' '),
|
||||
style: 'overflow: visible',
|
||||
xmlns: 'http://www.w3.org/2000/svg'
|
||||
};
|
||||
return attrs;
|
||||
}
|
||||
|
||||
static create_svg(create_fn, viewBox, children) {
|
||||
var svg = create_fn('svg', {
|
||||
attrs: ChartVue.svg_attrs(viewBox),
|
||||
children
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
static add_yAxisLegend(g, data, colors) {
|
||||
//var gtick = g.select(".tick:last-of-type").append("g");
|
||||
const h = ChartPrefs.axis_font_size;
|
||||
var gtick = g.append("g")
|
||||
.attr('transform',
|
||||
`translate(0, ${h * data.series.length})`);
|
||||
|
||||
gtick.selectAll('rect')
|
||||
.data(data.series)
|
||||
.join('rect')
|
||||
.attr('x', 3)
|
||||
.attr('y', (d, i) => -h + i*h)
|
||||
.attr('width', h)
|
||||
.attr('height', h)
|
||||
.attr('fill', (d, i) => colors[i]);
|
||||
gtick.selectAll('text')
|
||||
.data(data.series)
|
||||
.join('text')
|
||||
.attr('x', h + 6)
|
||||
.attr('y', (d, i) => i*h )
|
||||
.attr("text-anchor", "start")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("fill", 'currentColor')
|
||||
.text(d => d.name);
|
||||
return g;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Timeseries data layout: {
|
||||
* y: 'description',
|
||||
* binsize: Number, // size in minutes,
|
||||
* date_parse_format: '%Y-%m-%d',
|
||||
* dates: [ 'YYYY-MM-DD HH:MM:SS', ... ],
|
||||
* series: [
|
||||
* {
|
||||
* id: 'id',
|
||||
* name: 'series 1 desc',
|
||||
* values: [ Number, .... ]
|
||||
* },
|
||||
* {
|
||||
* id: 'id',
|
||||
* name: 'series 2 desc'
|
||||
* values: [ ... ],
|
||||
* },
|
||||
* ...
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
|
||||
class TimeseriesData {
|
||||
constructor(data) {
|
||||
Object.assign(this, data);
|
||||
this.convert_dates();
|
||||
}
|
||||
|
||||
get_series(id) {
|
||||
for (var i=0; i<this.series.length; i++) {
|
||||
if (this.series[i].id == id) return this.series[i];
|
||||
}
|
||||
}
|
||||
|
||||
dataView(desired_series_ids) {
|
||||
var dataview = Object.assign({}, this);
|
||||
dataview.series = [];
|
||||
|
||||
var desired = {}
|
||||
desired_series_ids.forEach(id => desired[id] = true);
|
||||
this.series.forEach(s => {
|
||||
if (desired[s.id]) dataview.series.push(s);
|
||||
});
|
||||
return new TimeseriesData(dataview);
|
||||
}
|
||||
|
||||
binsizeWithUnit() {
|
||||
// normalize binsize (which is a time span in minutes)
|
||||
const days = Math.floor(this.binsize / (24 * 60));
|
||||
const hours = Math.floor( (this.binsize - days*24*60) / 60 );
|
||||
const mins = this.binsize - days*24*60 - hours*60;
|
||||
if (days == 0 && hours == 0) {
|
||||
return {
|
||||
unit: 'minute',
|
||||
value: mins
|
||||
};
|
||||
}
|
||||
if (days == 0) {
|
||||
return {
|
||||
unit: 'hour',
|
||||
value: hours
|
||||
};
|
||||
}
|
||||
return {
|
||||
unit: 'day',
|
||||
value: days
|
||||
};
|
||||
}
|
||||
|
||||
binsizeTimespan() {
|
||||
/* return the binsize timespan in seconds */
|
||||
return this.binsize * 60;
|
||||
}
|
||||
|
||||
static binsizeOfRange(range) {
|
||||
// target 100-120 datapoints
|
||||
const target = 100;
|
||||
const tolerance = 0.2; // 20%
|
||||
|
||||
if (typeof range[0] == 'string') {
|
||||
var parser = d3.utcParse('%Y-%m-%d %H:%M:%S');
|
||||
range = range.map(parser);
|
||||
}
|
||||
|
||||
const span_min = Math.ceil(
|
||||
(range[1].getTime() - range[0].getTime()) / (1000*60*target)
|
||||
);
|
||||
const bin_days = Math.floor(span_min / (24*60));
|
||||
const bin_hours = Math.floor((span_min - bin_days*24*60) / 60);
|
||||
if (bin_days >= 1) {
|
||||
return bin_days * 24 * 60 +
|
||||
(bin_hours > (24 * tolerance) ? bin_hours*60: 0);
|
||||
}
|
||||
|
||||
const bin_mins = span_min - bin_days*24*60 - bin_hours*60;
|
||||
if (bin_hours >= 1) {
|
||||
return bin_hours * 60 +
|
||||
(bin_mins > (60 * tolerance) ? bin_mins: 0 );
|
||||
}
|
||||
return bin_mins;
|
||||
}
|
||||
|
||||
barwidth(xscale, barspacing) {
|
||||
/* get the width of a bar in a bar chart */
|
||||
var start = this.range[0];
|
||||
var end = this.range[1];
|
||||
var bins = (end.getTime() - start.getTime()) / (1000 * this.binsizeTimespan());
|
||||
return Math.max(1, (xscale.range()[1] - xscale.range()[0])/bins - (barspacing || 0));
|
||||
}
|
||||
|
||||
formatDateTimeLong(d) {
|
||||
var options = { hour: 'numeric' };
|
||||
var b = this.binsizeWithUnit();
|
||||
if (b.unit === 'minute') {
|
||||
options.minute = 'numeric';
|
||||
return DateFormatter.dt_long(d, options);
|
||||
}
|
||||
if (b.unit === 'hour') {
|
||||
return DateFormatter.dt_long(d, options);
|
||||
}
|
||||
if (b.unit === 'day') {
|
||||
return DateFormatter.d_long(d);
|
||||
}
|
||||
throw new Error(`Unknown binsize unit: ${b.unit}`);
|
||||
}
|
||||
|
||||
formatDateTimeShort(d) {
|
||||
var options = {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
weekday: undefined
|
||||
};
|
||||
var b = this.binsizeWithUnit();
|
||||
if (b.unit === 'minute') {
|
||||
Object.assign(options, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
});
|
||||
return DateFormatter.dt_long(d, options);
|
||||
}
|
||||
if (b.unit === 'hour') {
|
||||
options.hour = 'numeric';
|
||||
return DateFormatter.dt_long(d, options);
|
||||
}
|
||||
if (b.unit === 'day') {
|
||||
return DateFormatter.d_short(d);
|
||||
}
|
||||
throw new Error(`Unknown binsize unit: ${b.unit}`);
|
||||
}
|
||||
|
||||
|
||||
convert_dates() {
|
||||
// all dates from the server are UTC strings
|
||||
// convert to Date objects
|
||||
if (this.dates.length > 0 && typeof this.dates[0] == 'string')
|
||||
{
|
||||
var parser = d3.utcParse(this.date_parse_format);
|
||||
this.dates = this.dates.map(parser);
|
||||
}
|
||||
if (this.range.length > 0 && typeof this.range[0] == 'string')
|
||||
{
|
||||
var parser = d3.utcParse(this.range_parse_format);
|
||||
this.range = this.range.map(parser);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
class ConnectionDisposition {
|
||||
constructor(disposition) {
|
||||
const data = {
|
||||
'failed_login_attempt': {
|
||||
short_desc: 'failed login attempt',
|
||||
},
|
||||
'insecure': {
|
||||
short_desc: 'insecure connection'
|
||||
},
|
||||
'ok': {
|
||||
short_desc: 'normal, secure connection'
|
||||
},
|
||||
'reject': {
|
||||
short_desc: 'mail attempt rejected'
|
||||
},
|
||||
'suspected_scanner': {
|
||||
short_desc: 'suspected scanner'
|
||||
}
|
||||
};
|
||||
this.disposition = disposition;
|
||||
this.info = data[disposition];
|
||||
if (! this.info) {
|
||||
this.info = {
|
||||
short_desc: disposition.replace('_',' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get short_desc() {
|
||||
return this.info.short_desc;
|
||||
}
|
||||
|
||||
static formatter(disposition) {
|
||||
return new ConnectionDisposition(disposition).short_desc;
|
||||
}
|
||||
};
|
||||
165
management/reporting/ui/date-range-picker.js
Normal file
165
management/reporting/ui/date-range-picker.js
Normal file
@@ -0,0 +1,165 @@
|
||||
Vue.component('date-range-picker', {
|
||||
props: {
|
||||
start_range: [ String, Array ], // "ytd", "mtd", "wtd", or [start, end] where start and end are strings in format YYYY-MM-DD in localti
|
||||
recall_id: String, // save / recall from localStorage
|
||||
},
|
||||
template: '<div class="d-flex align-items-center flex-wrap">'+
|
||||
'<div>Date range:<br><b-form-select v-model="range_type" :options="options" size="sm" @change="range_type_change"></b-form-select></div>'+
|
||||
'<div class="ml-2">From:<br><b-form-datepicker v-model="range[0]" style="max-width:20rem" :disabled="range_type != \'custom\'"></b-form-datepicker></div>' +
|
||||
'<div class="ml-2">To:<br><b-form-datepicker v-model="range[1]" style="max-width:20rem" :min="range[0]" :disabled="range_type != \'custom\'"></b-form-datepicker></div>' +
|
||||
'</div>'
|
||||
,
|
||||
data: function() {
|
||||
var range_type = null;
|
||||
var range = null;
|
||||
var default_range_type = 'mtd';
|
||||
const recall_id_prefix = 'date-range-picker/';
|
||||
|
||||
var v = null;
|
||||
if (typeof this.start_range === 'string') {
|
||||
if (this.start_range.substring(0,1) == '-') {
|
||||
default_range_type = this.start_range.substring(1);
|
||||
}
|
||||
else {
|
||||
v = this.validate_input_range(this.start_range);
|
||||
}
|
||||
}
|
||||
else {
|
||||
v = this.validate_input_range(this.start_range);
|
||||
}
|
||||
|
||||
if (v) {
|
||||
// handles explicit valid "range-type", [ start, end ]
|
||||
range_type = v.range_type;
|
||||
range = v.range;
|
||||
}
|
||||
else if (this.recall_id) {
|
||||
const id = recall_id_prefix+this.recall_id;
|
||||
try {
|
||||
var v = JSON.parse(localStorage.getItem(id));
|
||||
range = v.range;
|
||||
range_type = v.range_type;
|
||||
} catch(e) {
|
||||
// pass
|
||||
console.error(e);
|
||||
console.log(localStorage.getItem(id));
|
||||
}
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
range_type = default_range_type;
|
||||
range = DateRange.rangeFromType(range_type)
|
||||
.map(DateFormatter.ymd);
|
||||
}
|
||||
|
||||
return {
|
||||
recall_id_full:(this.recall_id ?
|
||||
recall_id_prefix + this.recall_id :
|
||||
null),
|
||||
range: range,
|
||||
range_type: range_type,
|
||||
options: [
|
||||
{ value:'wtd', text:'Week-to-date' },
|
||||
{ value:'mtd', text:'Month-to-date' },
|
||||
{ value:'ytd', text:'Year-to-date' },
|
||||
{ value:'custom', text:'Custom' }
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
created: function() {
|
||||
this.notify_change(true);
|
||||
},
|
||||
|
||||
watch: {
|
||||
'range': function() {
|
||||
this.notify_change();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
validate_input_range: function(range) {
|
||||
// if range is a string it's a range_type (eg "ytd")
|
||||
// othersize its an 2-element array [start, end] of dates
|
||||
// in YYYY-MM-DD (localtime) format
|
||||
if (typeof range == 'string') {
|
||||
var dates = DateRange.rangeFromType(range)
|
||||
.map(DateFormatter.ymd);
|
||||
if (! range) return null;
|
||||
return { range:dates, range_type:range };
|
||||
}
|
||||
else if (range.length == 2) {
|
||||
var parser = d3.timeParse('%Y-%m-%d');
|
||||
if (! parser(range[0]) || !parser(range[1]))
|
||||
return null;
|
||||
return { range, range_type:'custom' };
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
set_range: function(range) {
|
||||
// if range is a string it's a range_type (eg "ytd")
|
||||
// othersize its an 2-element array [start, end] of dates
|
||||
// in YYYY-MM-DD (localtime) format
|
||||
var v = this.validate_input_range(range);
|
||||
if (!v) return false;
|
||||
this.range = v.range;
|
||||
this.range_type = v.range_type;
|
||||
this.notify_change();
|
||||
return true;
|
||||
},
|
||||
|
||||
notify_change: function(init) {
|
||||
var parser = d3.timeParse('%Y-%m-%d');
|
||||
|
||||
var end_utc = new Date();
|
||||
end_utc.setTime(
|
||||
parser(this.range[1]).getTime() + (24 * 60 * 60 * 1000)
|
||||
);
|
||||
var range_utc = [
|
||||
DateFormatter.ymdhms_utc(parser(this.range[0])),
|
||||
DateFormatter.ymdhms_utc(end_utc)
|
||||
];
|
||||
|
||||
this.$emit('change', {
|
||||
// localtime "YYYY-MM-DD" format - exactly what came
|
||||
// from the ui
|
||||
range: this.range,
|
||||
|
||||
// convert localtime to utc, include hours. add 1 day
|
||||
// to end so that range_utc encompasses times >=start
|
||||
// and <end. save in the format "YYYYY-MM-DD HH:MM:SS"
|
||||
range_utc: range_utc,
|
||||
|
||||
// 'custom', 'ytd', 'mtd', etc
|
||||
range_type: this.range_type,
|
||||
|
||||
// just created, if true
|
||||
init: init || false,
|
||||
});
|
||||
|
||||
if (this.recall_id_full) {
|
||||
localStorage.setItem(this.recall_id_full, JSON.stringify({
|
||||
range: this.range,
|
||||
range_type: this.range_type
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
range_type_change: function(evt) {
|
||||
// ui select callback
|
||||
if (this.range_type == 'wtd')
|
||||
this.range = DateRange.wtd_as_ymd();
|
||||
else if (this.range_type == 'mtd')
|
||||
this.range = DateRange.mtd_as_ymd();
|
||||
else if (this.range_type == 'ytd')
|
||||
this.range = DateRange.ytd_as_ymd();
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
58
management/reporting/ui/index.html
Normal file
58
management/reporting/ui/index.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title> Activity - MiaB-LDAP </title>
|
||||
<base href="/admin/">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<link rel="stylesheet" href="ui-common/ui-bootstrap.css"/>
|
||||
<link rel="stylesheet" href="ui-common/ui.css"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-vue@2/dist/bootstrap-vue.min.css"/>
|
||||
|
||||
<!-- vue, vue-router, bootstrap-vue, axios, d3 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
|
||||
<!--script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script-->
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue-router@3"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-vue@2/dist/bootstrap-vue.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap-vue@2/dist/bootstrap-vue-icons.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios"></script>
|
||||
<script src="https://d3js.org/d3.v6.min.js"></script>
|
||||
|
||||
<script>axios.defaults.baseURL="/admin"</script>
|
||||
|
||||
<!-- our code -->
|
||||
<script src="ui-common/exceptions.js"></script>
|
||||
<script src="ui-common/authentication.js"></script>
|
||||
<script src="ui-common/page-layout.js"></script>
|
||||
<script src="ui-common/page-header.js"></script>
|
||||
<script src="reports/ui/settings.js"></script>
|
||||
<script src="reports/ui/capture-db-stats.js"></script>
|
||||
<script src="reports/ui/charting.js"></script>
|
||||
<script src="reports/ui/wbr-text.js"></script>
|
||||
<script src="reports/ui/date-range-picker.js"></script>
|
||||
<script src="reports/ui/chart-pie.js"></script>
|
||||
<script src="reports/ui/chart-table.js"></script>
|
||||
<script src="reports/ui/chart-stacked-bar-timeseries.js"></script>
|
||||
<script src="reports/ui/chart-multi-line-timeseries.js"></script>
|
||||
<script src="reports/ui/panel-messages-sent.js"></script>
|
||||
<script src="reports/ui/panel-messages-received.js"></script>
|
||||
<script src="reports/ui/panel-flagged-connections.js"></script>
|
||||
<script src="reports/ui/panel-user-activity.js"></script>
|
||||
<script src="reports/ui/panel-remote-sender-activity.js"></script>
|
||||
|
||||
<script src="reports/ui/reports-page-header.js"></script>
|
||||
<script src="reports/ui/page-settings.js"></script>
|
||||
<script src="reports/ui/page-reports-main.js"></script>
|
||||
<script src="reports/ui/index.js"></script>
|
||||
</head>
|
||||
|
||||
<body onload="init_app()">
|
||||
|
||||
<router-view id="app"></router-view>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
75
management/reporting/ui/index.js
Normal file
75
management/reporting/ui/index.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* reports index page
|
||||
*/
|
||||
|
||||
|
||||
|
||||
const app = {
|
||||
router: new VueRouter({
|
||||
routes: [
|
||||
{ path: '/', component: Vue.component('page-reports-main') },
|
||||
{ path: '/settings', component: Vue.component('page-settings') },
|
||||
{ path: '/:panel', component: Vue.component('page-reports-main') },
|
||||
],
|
||||
scrollBehavior: function(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
components: {
|
||||
'page-settings': Vue.component('page-settings'),
|
||||
'page-reports-main': Vue.component('page-reports-main'),
|
||||
},
|
||||
|
||||
data: {
|
||||
me: null,
|
||||
},
|
||||
|
||||
mounted: function() {
|
||||
this.getMe();
|
||||
},
|
||||
|
||||
methods: {
|
||||
getMe: function() {
|
||||
axios.get('me').then(response => {
|
||||
this.me = new Me(response.data);
|
||||
}).catch(error => {
|
||||
this.handleError(error);
|
||||
});
|
||||
},
|
||||
|
||||
handleError: function(error) {
|
||||
if (error instanceof AuthenticationError) {
|
||||
console.log(error);
|
||||
window.location = '/admin';
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
if (error instanceof ReferenceError) {
|
||||
// uncaught coding bug, ignore
|
||||
return;
|
||||
}
|
||||
if (error.status && error.reason)
|
||||
{
|
||||
// axios
|
||||
error = error.reason;
|
||||
}
|
||||
this.$nextTick(() => {alert(''+error) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
function init_app() {
|
||||
init_axios_interceptors();
|
||||
|
||||
UserSettings.load().then(settings => {
|
||||
new Vue(app).$mount('#app');
|
||||
}).catch(error => {
|
||||
alert('' + error);
|
||||
});
|
||||
}
|
||||
94
management/reporting/ui/page-reports-main.html
Normal file
94
management/reporting/ui/page-reports-main.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<page-layout>
|
||||
|
||||
<template v-slot:header>
|
||||
<reports-page-header :loading_counter="loading"></reports-page-header>
|
||||
</template>
|
||||
|
||||
<!-- div -->
|
||||
|
||||
<b-modal ref="stats" hide-header no-fade ok-only no-close-on-backdrop>
|
||||
<capture-db-stats></capture-db-stats>
|
||||
</b-modal>
|
||||
|
||||
<div class="d-flex align-items-end">
|
||||
<date-range-picker ref="date_picker" :start_range="get_start_range($route, '-mtd')" recall_id="reports-main" @change="date_change($event)"></date-range-picker>
|
||||
<div class="ml-auto mr-1" title="Database stats" role="button" @click="$refs.stats.show()"><b-icon icon="server" scale="1.5" aria-label="Database stats" variant="primary"></b-icon><b-icon icon="info" scale="1.5"></b-icon></div>
|
||||
</div>
|
||||
|
||||
<b-navbar type="dark" variant="secondary" class="mt-1">
|
||||
<b-navbar-brand v-if="panel==''">Choose</b-navbar-brand>
|
||||
<b-navbar-nav style="font-size:1.2em">
|
||||
<b-nav-item
|
||||
:active="panel=='messages-sent'"
|
||||
:to="get_route('messages-sent')">Messages sent
|
||||
</b-nav-item>
|
||||
<b-nav-item
|
||||
:active="panel=='messages-received'"
|
||||
:to="get_route('messages-received')">Messages received
|
||||
</b-nav-item>
|
||||
<b-nav-item
|
||||
:active="panel=='user-activity'"
|
||||
:to="get_route('user-activity')">User activity
|
||||
</b-nav-item>
|
||||
<b-nav-item
|
||||
:active="panel=='remote-sender-activity'"
|
||||
:to="get_route('remote-sender-activity')">Remote sender activity
|
||||
</b-nav-item>
|
||||
<b-nav-item
|
||||
:active="panel=='flagged-connections'"
|
||||
:to="get_route('flagged-connections')">Notable connections
|
||||
</b-nav-item>
|
||||
</b-navbar-nav>
|
||||
</b-navbar>
|
||||
|
||||
<keep-alive>
|
||||
<panel-messages-sent
|
||||
v-if="panel=='messages-sent'"
|
||||
:date_range="range_utc"
|
||||
:binsize="get_binsize()"
|
||||
@loading="loading += $event"
|
||||
:user_link="get_route('user-activity')"
|
||||
class="mt-3">
|
||||
</panel-messages-sent>
|
||||
|
||||
<panel-messages-received
|
||||
v-if="panel=='messages-received'"
|
||||
:date_range="range_utc"
|
||||
:binsize="get_binsize()"
|
||||
@loading="loading += $event"
|
||||
:user_link="get_route('user-activity', {tab:1})"
|
||||
:remote_sender_email_link="get_route('remote-sender-activity')"
|
||||
:remote_sender_server_link="get_route('remote-sender-activity')"
|
||||
class="mt-3">
|
||||
</panel-messages-received>
|
||||
|
||||
<panel-user-activity
|
||||
v-if="panel=='user-activity'"
|
||||
:date_range="range_utc"
|
||||
@loading="loading += $event"
|
||||
class="mt-3">
|
||||
</panel-user-activity>
|
||||
|
||||
<panel-remote-sender-activity
|
||||
v-if="panel=='remote-sender-activity'"
|
||||
:date_range="range_utc"
|
||||
@loading="loading += $event"
|
||||
class="mt-3">
|
||||
</panel-remote-sender-activity>
|
||||
|
||||
<panel-flagged-connections
|
||||
v-if="panel=='flagged-connections'"
|
||||
:date_range="range_utc"
|
||||
:binsize="get_binsize()"
|
||||
@loading="loading += $event"
|
||||
:user_link="get_route('user-activity')"
|
||||
:remote_sender_email_link="get_route('remote-sender-activity')"
|
||||
:remote_sender_server_link="get_route('remote-sender-activity')"
|
||||
class="mt-3">
|
||||
</panel-flagged-connections>
|
||||
</keep-alive>
|
||||
|
||||
<!-- /div -->
|
||||
|
||||
|
||||
</page-layout>
|
||||
110
management/reporting/ui/page-reports-main.js
Normal file
110
management/reporting/ui/page-reports-main.js
Normal file
@@ -0,0 +1,110 @@
|
||||
Vue.component('page-reports-main', function(resolve, reject) {
|
||||
axios.get('reports/ui/page-reports-main.html').then((response) => { resolve({
|
||||
|
||||
template: response.data,
|
||||
|
||||
components: {
|
||||
'page-layout': Vue.component('page-layout'),
|
||||
'reports-page-header': Vue.component('reports-page-header'),
|
||||
'date-range-picker': Vue.component('date-range-picker'),
|
||||
'panel-messages-sent': Vue.component('panel-messages-sent'),
|
||||
'panel-messages-received': Vue.component('panel-messages-received'),
|
||||
'panel-flagged-connections': Vue.component('panel-flagged-connections'),
|
||||
'panel-user-activity': Vue.component('panel-user-activity'),
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
// page-header loading spinner
|
||||
loading: 0,
|
||||
|
||||
// panels
|
||||
panel: this.$route.params.panel || '',
|
||||
|
||||
// date picker - the range, if in the route, is set
|
||||
// via date_change(), called during date picker
|
||||
// create()
|
||||
range_utc: null, // Array(2): Date objects (UTC)
|
||||
range: null, // Array(2): YYYY-MM-DD (localtime)
|
||||
range_type: null, // String: "custom","ytd","mtd", etc
|
||||
};
|
||||
},
|
||||
|
||||
beforeRouteUpdate: function(to, from, next) {
|
||||
//console.log(`page route update: to=${JSON.stringify({path:to.path, query:to.query})} .... from=${JSON.stringify({path:from.path, query:from.query})}`);
|
||||
this.panel = to.params.panel;
|
||||
// note: the range is already set in the class data
|
||||
//
|
||||
// 1. at component start - the current range is extracted
|
||||
// from $route by get_route_range() and passed to the
|
||||
// date picker as a prop, which subsequently
|
||||
// $emits('change') during creation where date_change()
|
||||
// updates the class data.
|
||||
//
|
||||
// 2. during user interaction - the date picker
|
||||
// $emits('change') where date_change() updates the
|
||||
// class data.
|
||||
next();
|
||||
},
|
||||
|
||||
methods: {
|
||||
get_start_range: function(to, default_range) {
|
||||
if (to.query.range_type) {
|
||||
return to.query.range_type;
|
||||
}
|
||||
else if (to.query.start && to.query.end) {
|
||||
// start and end must be YYYY-MM-DD (localtime)
|
||||
return [
|
||||
to.query.start,
|
||||
to.query.end
|
||||
];
|
||||
}
|
||||
else {
|
||||
return default_range;
|
||||
}
|
||||
},
|
||||
|
||||
get_binsize: function() {
|
||||
if (! this.range_utc) return 0;
|
||||
return TimeseriesData.binsizeOfRange(this.range_utc);
|
||||
},
|
||||
|
||||
date_change: function(evt) {
|
||||
// date picker 'change' event
|
||||
this.range_type = evt.range_type;
|
||||
this.range_utc = evt.range_utc;
|
||||
this.range = evt.range;
|
||||
var route = this.get_route(this.panel);
|
||||
if (! evt.init) {
|
||||
this.$router.replace(route);
|
||||
}
|
||||
},
|
||||
|
||||
get_route: function(panel, ex_query) {
|
||||
// return vue-router route to `panel`
|
||||
// eg: "/<panel>?start=YYYY-MM-DD&end=YYYY-MM-DD"
|
||||
//
|
||||
// additional panel query elements should be set in
|
||||
// the panel's activate method
|
||||
var route = { path: panel };
|
||||
if (this.range_type != 'custom') {
|
||||
route.query = {
|
||||
range_type: this.range_type
|
||||
};
|
||||
}
|
||||
else {
|
||||
route.query = {
|
||||
start: this.range[0],
|
||||
end: this.range[1]
|
||||
};
|
||||
}
|
||||
Object.assign(route.query, ex_query);
|
||||
return route;
|
||||
},
|
||||
|
||||
}
|
||||
})}).catch((e) => {
|
||||
reject(e);
|
||||
});
|
||||
|
||||
});
|
||||
72
management/reporting/ui/page-settings.html
Normal file
72
management/reporting/ui/page-settings.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<page-layout>
|
||||
|
||||
<template v-slot:header>
|
||||
<reports-page-header :loading_counter="loading"></reports-page-header>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<div class="d-flex">
|
||||
<div>Settings</div>
|
||||
<router-link :to="from_route || '/'" class="ml-auto">Back to reports</router-link>
|
||||
</div>
|
||||
|
||||
|
||||
<b-card class="mt-2">
|
||||
<b-card-title>
|
||||
UI settings
|
||||
</b-card-title>
|
||||
|
||||
<b-card-body>
|
||||
<div class="d-flex align-items-baseline">
|
||||
<div class="mr-1">Table data row limit</div>
|
||||
<input type="number" min="5" v-model="row_limit" style="max-width:8em" v-on:keyup="update_user_settings"></input>
|
||||
<div class="text-danger ml-2">
|
||||
<em>{{row_limit_error}}</em>
|
||||
</div>
|
||||
</div>
|
||||
</b-card-body>
|
||||
</b-card>
|
||||
|
||||
|
||||
<b-card class="mt-2" v-if="capture_config && status">
|
||||
<b-card-title>
|
||||
Capture daemon
|
||||
</b-card-title>
|
||||
|
||||
<b-card-body>
|
||||
<h4 class="d-flex">
|
||||
<b-badge :variant="status_variant(status[0])">{{status[0]}}</b-badge>
|
||||
<b-badge class="ml-2" :variant="status_variant(status[1])">{{status[1]}}</b-badge>
|
||||
<b-badge class="ml-2" v-if="is_running()" :variant="status_variant(capture_config.capture)"> {{ capture_config.capture ? 'capturing' : 'paused' }}</b-badge>
|
||||
</h4>
|
||||
<p><i>(systemd service "miabldap-capture")</i></p>
|
||||
|
||||
<b-form @submit.prevent class="mt-3" v-if="is_running()">
|
||||
<b-form-checkbox v-model="capture" @change="config_changed=true">
|
||||
Capture enabled
|
||||
</b-form-checkbox> <em class="text-danger">Warning: when capture is disabled, the daemon will no longer record log activity</em>
|
||||
<div class="d-flex align-items-baseline">
|
||||
<div class="mr-1">Delete database records older than </div>
|
||||
<input type="number" min="0" v-model="older_than_days" style="max-width:6em" v-on:keyup="config_changed_if(older_than_days, 0, null, capture_config.prune_policy.older_than_days)"></input>
|
||||
<div class="ml-1">days</div>
|
||||
</div>
|
||||
<div class="mb-3 ml-2">
|
||||
<em>(a value of zero preserves all records)</em>
|
||||
</div>
|
||||
|
||||
<b-form-checkbox v-model="capture_config.drop_disposition.faild_login_attempt" @change="config_changed=true">Ignore failed login attempts</b-form-checkbox>
|
||||
<b-form-checkbox v-model="capture_config.drop_disposition.suspected_scanner" @change="config_changed=true">Ignore suspected scanner activity</b-form-checkbox>
|
||||
<b-form-checkbox v-model="capture_config.drop_disposition.reject" @change="config_changed=true">Ignore rejected mail attempts</b-form-checkbox>
|
||||
|
||||
</b-form>
|
||||
|
||||
<div v-if="config_changed" class="mt-3">
|
||||
<b-button variant="danger" @click="save_capture_config()">Commit changes and update server</b-button>
|
||||
</div>
|
||||
|
||||
</b-card-body>
|
||||
</b-card>
|
||||
|
||||
</div>
|
||||
|
||||
</page-layout>
|
||||
123
management/reporting/ui/page-settings.js
Normal file
123
management/reporting/ui/page-settings.js
Normal file
@@ -0,0 +1,123 @@
|
||||
Vue.component('page-settings', function(resolve, reject) {
|
||||
axios.get('reports/ui/page-settings.html').then((response) => { resolve({
|
||||
|
||||
template: response.data,
|
||||
|
||||
components: {
|
||||
'page-layout': Vue.component('page-layout'),
|
||||
'reports-page-header': Vue.component('reports-page-header'),
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
from_route: null,
|
||||
loading: 0,
|
||||
|
||||
// server status and config
|
||||
capture_config: null,
|
||||
config_changed: false,
|
||||
status: null,
|
||||
|
||||
// capture config models that require processing
|
||||
// before the value is valid for `capture_config`, or
|
||||
// the `capture_config` value is used by multiple
|
||||
// elements (eg. one showing current state)
|
||||
capture: true,
|
||||
older_than_days: '',
|
||||
|
||||
// user settings
|
||||
row_limit: '' + UserSettings.get().row_limit,
|
||||
row_limit_error: ''
|
||||
};
|
||||
},
|
||||
|
||||
beforeRouteEnter: function(to, from, next) {
|
||||
next(vm => {
|
||||
vm.from_route = from;
|
||||
});
|
||||
},
|
||||
|
||||
created: function() {
|
||||
this.loadData();
|
||||
},
|
||||
|
||||
methods: {
|
||||
is_running: function() {
|
||||
return this.status[0] == 'running';
|
||||
},
|
||||
|
||||
status_variant: function(status) {
|
||||
if (status == 'running') return 'success';
|
||||
if (status == 'enabled') return 'success';
|
||||
if (status == 'disabled') return 'warning';
|
||||
if (status === true) return 'success';
|
||||
return 'danger'
|
||||
},
|
||||
|
||||
loadData: function() {
|
||||
this.loading += 1;
|
||||
Promise.all([
|
||||
CaptureConfig.get(),
|
||||
axios.get('/reports/capture/service/status')
|
||||
]).then(responses => {
|
||||
this.capture_config = responses[0];
|
||||
if (this.capture_config.status !== 'error') {
|
||||
this.older_than_days = '' +
|
||||
this.capture_config.prune_policy.older_than_days;
|
||||
this.capture = this.capture_config.capture;
|
||||
}
|
||||
this.status = responses[1].data;
|
||||
this.config_changed = false;
|
||||
}).catch(error => {
|
||||
this.$root.handleError(error);
|
||||
}).finally(() => {
|
||||
this.loading -= 1;
|
||||
});
|
||||
},
|
||||
|
||||
update_user_settings: function() {
|
||||
if (this.row_limit == '') {
|
||||
this.row_limit_error = 'not valid';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const s = UserSettings.get();
|
||||
s.row_limit = Number(this.row_limit);
|
||||
} catch(e) {
|
||||
this.row_limit_error = e.message;
|
||||
}
|
||||
},
|
||||
|
||||
config_changed_if: function(v, range_min, range_max, current_value) {
|
||||
v = Number(v);
|
||||
if (range_min !== null && v < range_min) return;
|
||||
if (range_max !== null && v > range_max) return;
|
||||
if (current_value !== null && v == current_value) return;
|
||||
this.config_changed = true;
|
||||
},
|
||||
|
||||
save_capture_config: function() {
|
||||
this.loading+=1;
|
||||
var newconfig = Object.assign({}, this.capture_config);
|
||||
this.capture_config.prune_policy.older_than_days =
|
||||
Number(this.older_than_days);
|
||||
newconfig.capture = this.capture;
|
||||
axios.post('/reports/capture/config', newconfig)
|
||||
.then(response => {
|
||||
this.loadData();
|
||||
})
|
||||
.catch(error => {
|
||||
this.$root.handleError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading-=1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
})}).catch((e) => {
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
87
management/reporting/ui/panel-flagged-connections.html
Normal file
87
management/reporting/ui/panel-flagged-connections.html
Normal file
@@ -0,0 +1,87 @@
|
||||
<div>
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
<div class="p-2">
|
||||
<strong>Connections by disposition</strong>
|
||||
<chart-pie
|
||||
:chart_data="connections_by_disposition"
|
||||
:name_formatter="disposition_formatter"
|
||||
:labels="false"
|
||||
:width="radius_pie *2"
|
||||
:height="radius_pie *2">
|
||||
</chart-pie>
|
||||
</div>
|
||||
|
||||
<chart-multi-line-timeseries
|
||||
class="p-2"
|
||||
:chart_data="failed_logins"
|
||||
:width="width"
|
||||
:height="linechart_height">
|
||||
</chart-multi-line-timeseries>
|
||||
|
||||
<chart-multi-line-timeseries
|
||||
class="p-2"
|
||||
:chart_data="suspected_scanners"
|
||||
:width="width"
|
||||
:height="linechart_height">
|
||||
</chart-multi-line-timeseries>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-start">
|
||||
<chart-table
|
||||
v-if="insecure_inbound"
|
||||
:items="insecure_inbound.items"
|
||||
:fields="insecure_inbound.fields"
|
||||
:caption="insecure_inbound.y"
|
||||
class="ml-4 mt-2"
|
||||
style="max-width:50px">
|
||||
<template #cell(sasl_username)="data">
|
||||
<router-link class="text-dark" :to='link_to_user(data.value, 1)'>{{ data.value }}</router-link>
|
||||
</template>
|
||||
<template #cell(envelope_from)="data">
|
||||
<router-link class="text-dark" :to='link_to_remote_sender_email(data.value)'>{{ data.value }}</router-link>
|
||||
</template>
|
||||
<template #cell(rcpt_to)="data">
|
||||
<router-link class="text-dark" :to='link_to_user(data.value, 1)'>{{ data.value }}</router-link>
|
||||
</template>
|
||||
</chart-table>
|
||||
|
||||
<chart-table
|
||||
v-if="insecure_outbound"
|
||||
:items="insecure_outbound.items"
|
||||
:fields="insecure_outbound.fields"
|
||||
:caption="insecure_outbound.y"
|
||||
class="ml-4 mt-2"
|
||||
style="max-width:50px">
|
||||
<template #cell(sasl_username)="data">
|
||||
<router-link class="text-dark" :to='link_to_user(data.value)'>{{ data.value }}</router-link>
|
||||
</template>
|
||||
</chart-table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center">
|
||||
<div class="p-2">
|
||||
<strong>Mail delivery rejects by category</strong>
|
||||
<chart-pie
|
||||
:chart_data="reject_by_failure_category"
|
||||
:labels="false"
|
||||
:width="radius_pie *2"
|
||||
:height="radius_pie *2">
|
||||
</chart-pie>
|
||||
</div>
|
||||
|
||||
<chart-table
|
||||
v-if="top_hosts_rejected"
|
||||
:items="top_hosts_rejected.items"
|
||||
:fields="top_hosts_rejected.fields"
|
||||
:caption="top_hosts_rejected.y"
|
||||
class="ml-4 mt-2"
|
||||
style="max-width:50px">
|
||||
<template #cell(remote_host)="data">
|
||||
<router-link class="text-dark" :to='link_to_remote_sender_server(data.value)'>{{ data.value }}</router-link>
|
||||
</template>
|
||||
</chart-table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
133
management/reporting/ui/panel-flagged-connections.js
Normal file
133
management/reporting/ui/panel-flagged-connections.js
Normal file
@@ -0,0 +1,133 @@
|
||||
Vue.component('panel-flagged-connections', function(resolve, reject) {
|
||||
axios.get('reports/ui/panel-flagged-connections.html').then((response) => { resolve({
|
||||
|
||||
template: response.data,
|
||||
|
||||
props: {
|
||||
date_range: Array, // YYYY-MM-DD strings (UTC)
|
||||
binsize: Number, // for timeseries charts, in minutes
|
||||
user_link: Object, // a route
|
||||
remote_sender_email_link: Object, // a route
|
||||
remote_sender_server_link: Object, // a route
|
||||
width: { type:Number, default: ChartPrefs.default_width },
|
||||
height: { type:Number, default: ChartPrefs.default_height },
|
||||
},
|
||||
|
||||
components: {
|
||||
'chart-multi-line-timeseries': Vue.component('chart-multi-line-timeseries'),
|
||||
'chart-stacked-bar-timeseries': Vue.component('chart-stacked-bar-timeseries'),
|
||||
'chart-pie': Vue.component('chart-pie'),
|
||||
'chart-table': Vue.component('chart-table'),
|
||||
},
|
||||
|
||||
computed: {
|
||||
radius_pie: function() {
|
||||
return this.height / 5;
|
||||
},
|
||||
linechart_height: function() {
|
||||
return this.height / 2;
|
||||
}
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
data_date_range: null,
|
||||
colors: ChartPrefs.colors,
|
||||
failed_logins: null, // TimeseriesData
|
||||
suspected_scanners: null, // TimeseriesData
|
||||
connections_by_disposition: null, // pie chart data
|
||||
disposition_formatter: ConnectionDisposition.formatter,
|
||||
reject_by_failure_category: null, // pie chart data
|
||||
top_hosts_rejected: null, // table
|
||||
insecure_inbound: null, // table
|
||||
insecure_outbound: null, // table
|
||||
};
|
||||
},
|
||||
|
||||
activated: function() {
|
||||
// see if props changed when deactive
|
||||
if (this.date_range && this.date_range !== this.data_date_range)
|
||||
this.getChartData();
|
||||
},
|
||||
|
||||
watch: {
|
||||
// watch props for changes
|
||||
'date_range': function() {
|
||||
this.getChartData();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
link_to_user: function(user_id, tab) {
|
||||
// add user=user_id to the user_link route
|
||||
var r = Object.assign({}, this.user_link);
|
||||
r.query = Object.assign({}, this.user_link.query);
|
||||
r.query.user = user_id;
|
||||
r.query.tab = tab;
|
||||
return r;
|
||||
},
|
||||
link_to_remote_sender_email: function(email) {
|
||||
// add email=email to the remote_sender_email route
|
||||
var r = Object.assign({}, this.remote_sender_email_link);
|
||||
r.query = Object.assign({}, this.remote_sender_email_link.query);
|
||||
r.query.email = email;
|
||||
return r;
|
||||
},
|
||||
link_to_remote_sender_server: function(server) {
|
||||
// add server=server to the remote_sender_server route
|
||||
var r = Object.assign({}, this.remote_sender_server_link);
|
||||
r.query = Object.assign({}, this.remote_sender_server_link.query);
|
||||
r.query.server = server;
|
||||
return r;
|
||||
},
|
||||
|
||||
getChartData: function() {
|
||||
this.$emit('loading', 1);
|
||||
axios.post('reports/uidata/flagged-connections', {
|
||||
'start': this.date_range[0],
|
||||
'end': this.date_range[1],
|
||||
'binsize': this.binsize,
|
||||
}).then(response => {
|
||||
this.data_date_range = this.date_range;
|
||||
|
||||
// line charts
|
||||
var ts = new TimeseriesData(response.data.flagged);
|
||||
this.failed_logins =
|
||||
ts.dataView(['failed_login_attempt']);
|
||||
this.suspected_scanners =
|
||||
ts.dataView(['suspected_scanner']);
|
||||
|
||||
// pie chart for connections by disposition
|
||||
this.connections_by_disposition =
|
||||
response.data.connections_by_disposition;
|
||||
|
||||
// pie chart for reject by failure_category
|
||||
this.reject_by_failure_category =
|
||||
response.data.reject_by_failure_category;
|
||||
|
||||
// table of top 10 hosts rejected by failure_category
|
||||
this.top_hosts_rejected =
|
||||
new BvTable(response.data.top_hosts_rejected);
|
||||
|
||||
// insecure connections tables
|
||||
this.insecure_inbound
|
||||
= new BvTable(response.data.insecure_inbound);
|
||||
this.insecure_outbound
|
||||
= new BvTable(response.data.insecure_outbound);
|
||||
|
||||
}).catch(error => {
|
||||
this.$root.handleError(error);
|
||||
}).finally(() => {
|
||||
this.$emit('loading', -1);
|
||||
});
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
})}).catch((e) => {
|
||||
reject(e);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
64
management/reporting/ui/panel-messages-received.html
Normal file
64
management/reporting/ui/panel-messages-received.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<div>
|
||||
<div class="d-flex flex-wrap align-items-top">
|
||||
|
||||
<chart-multi-line-timeseries
|
||||
class="pt-1"
|
||||
:chart_data="data_received"
|
||||
:width="width"
|
||||
:height="height/2">
|
||||
</chart-multi-line-timeseries>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-top">
|
||||
<chart-table
|
||||
v-if="top_senders_by_count"
|
||||
:items="top_senders_by_count.items"
|
||||
:fields="top_senders_by_count.fields"
|
||||
:caption="top_senders_by_count.y"
|
||||
class="ml-4 mt-2"
|
||||
style="max-width:50px">
|
||||
<template #cell(email)="data">
|
||||
<router-link class="text-dark" :to='link_to_remote_sender_email(data.value)'>{{ data.value }}</router-link>
|
||||
</template>
|
||||
</chart-table>
|
||||
|
||||
<chart-table
|
||||
v-if="top_senders_by_size"
|
||||
:items="top_senders_by_size.items"
|
||||
:fields="top_senders_by_size.fields"
|
||||
:caption="top_senders_by_size.y"
|
||||
class="ml-4 mt-2"
|
||||
style="max-width:50px">
|
||||
<template #cell(email)="data">
|
||||
<router-link class="text-dark" :to='link_to_remote_sender_email(data.value)'>{{ data.value }}</router-link>
|
||||
</template>
|
||||
</chart-table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-top">
|
||||
<chart-table
|
||||
v-if="top_hosts_by_spam_score"
|
||||
:items="top_hosts_by_spam_score.items"
|
||||
:fields="top_hosts_by_spam_score.fields"
|
||||
:caption="top_hosts_by_spam_score.y"
|
||||
class="ml-4 mt-2"
|
||||
style="max-width:50px">
|
||||
<template #cell(remote_host)="data">
|
||||
<router-link class="text-dark" :to='link_to_remote_sender_server(data.value)'>{{ data.value }}</router-link>
|
||||
</template>
|
||||
</chart-table>
|
||||
|
||||
<chart-table
|
||||
v-if="top_user_receiving_spam"
|
||||
:items="top_user_receiving_spam.items"
|
||||
:fields="top_user_receiving_spam.fields"
|
||||
:caption="top_user_receiving_spam.y"
|
||||
class="ml-4 mt-2"
|
||||
style="max-width:50px">
|
||||
<template #cell(rcpt_to)="data">
|
||||
<router-link class="text-dark" :to='link_to_user(data.value)'>{{ data.value }}</router-link>
|
||||
</template>
|
||||
</chart-table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
116
management/reporting/ui/panel-messages-received.js
Normal file
116
management/reporting/ui/panel-messages-received.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
set of charts/tables showing messages received from internet servers
|
||||
|
||||
*/
|
||||
|
||||
Vue.component('panel-messages-received', function(resolve, reject) {
|
||||
axios.get('reports/ui/panel-messages-received.html').then((response) => { resolve({
|
||||
|
||||
template: response.data,
|
||||
|
||||
props: {
|
||||
date_range: Array,
|
||||
binsize: Number,
|
||||
user_link: Object,
|
||||
remote_sender_email_link: Object,
|
||||
remote_sender_server_link: Object,
|
||||
width: { type:Number, default: ChartPrefs.default_width },
|
||||
height: { type:Number, default: ChartPrefs.default_height },
|
||||
},
|
||||
|
||||
components: {
|
||||
'chart-multi-line-timeseries': Vue.component('chart-multi-line-timeseries'),
|
||||
// 'chart-stacked-bar-timeseries': Vue.component('chart-stacked-bar-timeseries'),
|
||||
// 'chart-pie': Vue.component('chart-pie'),
|
||||
'chart-table': Vue.component('chart-table'),
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
data_date_range: null,
|
||||
data_received: null,
|
||||
top_senders_by_count: null,
|
||||
top_senders_by_size: null,
|
||||
top_hosts_by_spam_score: null,
|
||||
top_user_receiving_spam: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
},
|
||||
|
||||
activated: function() {
|
||||
// see if props changed when deactive
|
||||
if (this.date_range && this.date_range !== this.data_date_range)
|
||||
this.getChartData();
|
||||
},
|
||||
|
||||
watch: {
|
||||
// watch props for changes
|
||||
'date_range': function() {
|
||||
this.getChartData();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
link_to_user: function(user_id) {
|
||||
// add user=user_id to the user_link route
|
||||
var r = Object.assign({}, this.user_link);
|
||||
r.query = Object.assign({}, this.user_link.query);
|
||||
r.query.user = user_id;
|
||||
return r;
|
||||
},
|
||||
link_to_remote_sender_email: function(email) {
|
||||
// add email=email to the remote_sender_email route
|
||||
var r = Object.assign({}, this.remote_sender_email_link);
|
||||
r.query = Object.assign({}, this.remote_sender_email_link.query);
|
||||
r.query.email = email;
|
||||
return r;
|
||||
},
|
||||
link_to_remote_sender_server: function(server) {
|
||||
// add server=server to the remote_sender_server route
|
||||
var r = Object.assign({}, this.remote_sender_server_link);
|
||||
r.query = Object.assign({}, this.remote_sender_server_link.query);
|
||||
r.query.server = server;
|
||||
return r;
|
||||
},
|
||||
|
||||
getChartData: function() {
|
||||
this.$emit('loading', 1);
|
||||
axios.post('reports/uidata/messages-received', {
|
||||
'start': this.date_range[0],
|
||||
'end': this.date_range[1],
|
||||
'binsize': this.binsize,
|
||||
}).then(response => {
|
||||
this.data_date_range = this.date_range;
|
||||
var ts = new TimeseriesData(response.data.ts_received);
|
||||
this.data_received = ts;
|
||||
|
||||
[ 'top_senders_by_count',
|
||||
'top_senders_by_size',
|
||||
'top_hosts_by_spam_score',
|
||||
'top_user_receiving_spam'
|
||||
].forEach(item => {
|
||||
this[item] = response.data[item];
|
||||
BvTable.setFieldDefinitions(
|
||||
this[item].fields,
|
||||
this[item].field_types
|
||||
);
|
||||
});
|
||||
|
||||
}).catch(error => {
|
||||
this.$root.handleError(error);
|
||||
}).finally(() => {
|
||||
this.$emit('loading', -1);
|
||||
});
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
})}).catch((e) => {
|
||||
reject(e);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
50
management/reporting/ui/panel-messages-sent.html
Normal file
50
management/reporting/ui/panel-messages-sent.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<div>
|
||||
<div class="d-flex flex-wrap align-items-center">
|
||||
<chart-multi-line-timeseries
|
||||
class="pt-1"
|
||||
:chart_data="data_sent"
|
||||
:width="width"
|
||||
:height="height_sent">
|
||||
</chart-multi-line-timeseries>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-top">
|
||||
<chart-table
|
||||
v-if="top_senders_by_count"
|
||||
:items="top_senders_by_count.items"
|
||||
:fields="top_senders_by_count.fields"
|
||||
:caption="top_senders_by_count.y"
|
||||
class="ml-4 mt-2"
|
||||
style="max-width:50px">
|
||||
<template #cell(user)="data">
|
||||
<router-link class="text-dark" :to="link_to_user(data.value)">{{ data.value }}</router-link>
|
||||
</template>
|
||||
</chart-table>
|
||||
|
||||
<chart-table
|
||||
v-if="top_senders_by_size"
|
||||
:items="top_senders_by_size.items"
|
||||
:fields="top_senders_by_size.fields"
|
||||
:caption="top_senders_by_size.y"
|
||||
class="ml-4 mt-2"
|
||||
style="max-width:50px">
|
||||
<template #cell(user)="data">
|
||||
<router-link class="text-dark" :to="link_to_user(data.value)">{{ data.value }}</router-link>
|
||||
</template>
|
||||
</chart-table>
|
||||
</div>
|
||||
|
||||
<chart-stacked-bar-timeseries
|
||||
class="pt-1"
|
||||
:chart_data="data_recip"
|
||||
:width="width"
|
||||
:height="height_recip">
|
||||
</chart-stacked-bar-timeseries>
|
||||
|
||||
<chart-pie
|
||||
class="ml-4"
|
||||
:chart_data="data_recip_pie"
|
||||
:width="radius_recip_pie *2"
|
||||
:height="radius_recip_pie *2">
|
||||
</chart-pie>
|
||||
</div>
|
||||
</div>
|
||||
132
management/reporting/ui/panel-messages-sent.js
Normal file
132
management/reporting/ui/panel-messages-sent.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
set of charts/tables showing messages sent by local users
|
||||
- number of messages sent over time
|
||||
- delivered locally
|
||||
- delivered remotely
|
||||
- top senders
|
||||
|
||||
emits:
|
||||
'loading' event=number
|
||||
*/
|
||||
|
||||
Vue.component('panel-messages-sent', function(resolve, reject) {
|
||||
axios.get('reports/ui/panel-messages-sent.html').then((response) => { resolve({
|
||||
|
||||
template: response.data,
|
||||
|
||||
props: {
|
||||
date_range: Array, // YYYY-MM-DD strings (UTC)
|
||||
binsize: Number, // for timeseries charts, in minutes
|
||||
// to enable clickable users, specify the route in
|
||||
// user_link. the user_id will be added to the
|
||||
// route.query as user=user_id. If set to 'true', the
|
||||
// current route will be used.
|
||||
user_link: Object,
|
||||
width: { type:Number, default: ChartPrefs.default_width },
|
||||
height: { type:Number, default: ChartPrefs.default_height },
|
||||
},
|
||||
|
||||
components: {
|
||||
'chart-multi-line-timeseries': Vue.component('chart-multi-line-timeseries'),
|
||||
'chart-stacked-bar-timeseries': Vue.component('chart-stacked-bar-timeseries'),
|
||||
'chart-pie': Vue.component('chart-pie'),
|
||||
'chart-table': Vue.component('chart-table'),
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
data_date_range: null,
|
||||
data_sent: null,
|
||||
data_recip: null,
|
||||
data_recip_pie: null,
|
||||
top_senders_by_count: null,
|
||||
top_senders_by_size: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
height_sent: function() {
|
||||
return this.height / 2;
|
||||
},
|
||||
|
||||
height_recip: function() {
|
||||
return this.height / 2;
|
||||
},
|
||||
|
||||
radius_recip_pie: function() {
|
||||
return this.height /5;
|
||||
},
|
||||
},
|
||||
|
||||
activated: function() {
|
||||
// see if props changed when deactive
|
||||
if (this.date_range && this.date_range !== this.data_date_range)
|
||||
this.getChartData();
|
||||
},
|
||||
|
||||
watch: {
|
||||
// watch props for changes
|
||||
'date_range': function() {
|
||||
this.getChartData();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
link_to_user: function(user_id) {
|
||||
// add user=user_id to the user_link route
|
||||
var r = Object.assign({}, this.user_link);
|
||||
r.query = Object.assign({}, this.user_link.query);
|
||||
r.query.user = user_id;
|
||||
return r;
|
||||
},
|
||||
|
||||
getChartData: function() {
|
||||
this.$emit('loading', 1);
|
||||
axios.post('reports/uidata/messages-sent', {
|
||||
'start': this.date_range[0],
|
||||
'end': this.date_range[1],
|
||||
'binsize': this.binsize,
|
||||
}).then(response => {
|
||||
this.data_date_range = this.date_range;
|
||||
var ts = new TimeseriesData(response.data.ts_sent);
|
||||
this.data_sent = ts.dataView(['sent']);
|
||||
this.data_recip = ts.dataView(['local','remote'])
|
||||
|
||||
this.data_recip_pie = [{
|
||||
name:'local',
|
||||
value:d3.sum(ts.get_series('local').values)
|
||||
}, {
|
||||
name:'remote',
|
||||
value:d3.sum(ts.get_series('remote').values)
|
||||
}];
|
||||
|
||||
this.top_senders_by_count =
|
||||
response.data.top_senders_by_count;
|
||||
BvTable.setFieldDefinitions(
|
||||
this.top_senders_by_count.fields,
|
||||
this.top_senders_by_count.field_types
|
||||
);
|
||||
|
||||
this.top_senders_by_size =
|
||||
response.data.top_senders_by_size;
|
||||
BvTable.setFieldDefinitions(
|
||||
this.top_senders_by_size.fields,
|
||||
this.top_senders_by_size.field_types
|
||||
);
|
||||
|
||||
}).catch(error => {
|
||||
this.$root.handleError(error);
|
||||
}).finally(() => {
|
||||
this.$emit('loading', -1);
|
||||
});
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
|
||||
})}).catch((e) => {
|
||||
reject(e);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
74
management/reporting/ui/panel-remote-sender-activity.html
Normal file
74
management/reporting/ui/panel-remote-sender-activity.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<div>
|
||||
|
||||
<b-modal ref="suggest_modal" scrollable header-bg-variant="dark" header-text-variant="light" ok-only ok-title="close" no-close-on-backdrop>
|
||||
<template #modal-title>
|
||||
{{ select_list.suggestions.length }} suggestions found
|
||||
</template>
|
||||
<div v-if="select_list.limited" class="text-danger">Too many results - the server returned only a limited set.</div>
|
||||
<template v-if="select_list.suggestions.length>0">
|
||||
<div>Choose one:</div>
|
||||
<div v-for="suggestion in select_list.suggestions" class="text-nowrap">
|
||||
<a href="" @click.prevent="choose_suggestion(suggestion)">
|
||||
{{ suggestion }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>nothing matched</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
|
||||
<datalist id="panel-rsa-recent">
|
||||
<option v-if="recent_senders" v-for="s in recent_senders">{{ s }}</option>
|
||||
</datalist>
|
||||
|
||||
<b-form @submit.prevent.stop="change_sender" class="d-flex mb-3">
|
||||
<div class="d-flex mr-2" style="margin-top:0.25rem" title="Sender type">
|
||||
<b-form-radio v-model="sender_type" value="email" @change="update_recent_list()">Email</b-form-radio>
|
||||
<b-form-radio class="ml-1" v-model="sender_type" value="server" @change="update_recent_list()">Server</b-form-radio>
|
||||
</div>
|
||||
<b-input-group style="width:40em">
|
||||
<b-form-input v-if="sender_type=='email'" class="h-auto" :autofocus="data_sender===null" list="panel-rsa-recent" v-model="email" placeholder="Enter an email address (envelope FROM)"></b-form-input>
|
||||
<b-form-input v-else class="h-auto" :autofocus="data_sender===null" list="panel-rsa-recent" v-model="server" placeholder="Enter a hostname or ip address"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button variant="primary" @click="change_sender" :disabled="sender_type=='email' && (email == '' || email==data_sender) || sender_type=='server' && (server =='' || server==data_sender)">Search</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<b-alert variant="warning" class="ml-2" :show="activity && activity.items.length>=get_row_limit()"><sup>*</sup> Tables limited to {{ get_row_limit() }} rows <router-link to="/settings"><b-icon icon="gear-fill"></b-icon></router-link></b-alert>
|
||||
<b-form-checkbox class="ml-auto" v-model="show_only_flagged" @change="show_only_flagged_change()">Flagged only</b-form-checkbox>
|
||||
</b-form>
|
||||
|
||||
<b-tabs content-class="mt2" v-model="tab_index" v-if="activity">
|
||||
<b-tab>
|
||||
<template #title>
|
||||
{{ data_sender || ''}}<sup v-if="activity.items.length >= get_row_limit()">*</sup> ({{activity.unique_sends}} → {{activity.items.length}})
|
||||
</template>
|
||||
<b-table
|
||||
class="sticky-table-header-0 bg-light"
|
||||
small
|
||||
:filter="show_only_flagged_filter"
|
||||
:filter-function="table_filter_cb"
|
||||
tbody-tr-class="cursor-pointer"
|
||||
details-td-class="cursor-default"
|
||||
@row-clicked="row_clicked"
|
||||
:items="activity.items"
|
||||
:fields="activity.fields">
|
||||
<template #row-details="row">
|
||||
<b-card>
|
||||
<div><strong>Connection disposition</strong>: {{ disposition_formatter(row.item.disposition) }}</div>
|
||||
<div v-if="row.item.sasl_username"><strong>Sasl username</strong>: {{row.item.sasl_username}}</div>
|
||||
<div v-if="row.item.category"><strong>Failure category</strong>: {{row.item.category}}</div>
|
||||
<div v-if="row.item.failure_info"><strong>Failure info</strong>: {{row.item.failure_info}}</div>
|
||||
<div v-if="row.item.dkim_reason"><strong>Dkim reason</strong>: {{row.item.dkim_reason}}</div>
|
||||
<div v-if="row.item.dmarc_reason"><strong>Dmarc reason</strong>: {{row.item.dmarc_reason}}</div>
|
||||
<div v-if="row.item.postgrey_reason"><strong>Postgrey reason</strong>: {{row.item.postgrey_reason}}</div>
|
||||
<div v-if="row.item.postgrey_delay"><strong>Postgrey delay</strong>: {{activity.x_fields.postgrey_delay.formatter(row.item.postgrey_delay)}}</div>
|
||||
<div v-if="row.item.spam_result"><strong>Spam score</strong>: {{activity.x_fields.spam_score.formatter(row.item.spam_score)}}</div>
|
||||
</b-card>
|
||||
</template>
|
||||
|
||||
</b-table>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
|
||||
</div>
|
||||
247
management/reporting/ui/panel-remote-sender-activity.js
Normal file
247
management/reporting/ui/panel-remote-sender-activity.js
Normal file
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
details on the activity of a remote sender (envelope from)
|
||||
*/
|
||||
|
||||
Vue.component('panel-remote-sender-activity', function(resolve, reject) {
|
||||
axios.get('reports/ui/panel-remote-sender-activity.html').then((response) => { resolve({
|
||||
|
||||
template: response.data,
|
||||
|
||||
props: {
|
||||
date_range: Array, // YYYY-MM-DD strings (UTC)
|
||||
},
|
||||
|
||||
data: function() {
|
||||
const usersetting_prefix = 'panel-rsa-';
|
||||
const sender_type = this.$route.query.email ? 'email' :
|
||||
( this.$route.query.server ? 'server' : 'email' );
|
||||
|
||||
return {
|
||||
email: this.$route.query.email || '', /* v-model */
|
||||
server: this.$route.query.server || '', /* v-model */
|
||||
sender_type: sender_type, /* "email" or "server" only */
|
||||
|
||||
tab_index: 0, /* v-model */
|
||||
|
||||
show_only_flagged: false,
|
||||
show_only_flagged_filter: null,
|
||||
|
||||
data_sender: null, /* sender for active table data */
|
||||
data_sender_type: null, /* "email" or "server" */
|
||||
data_date_range: null, /* date range for active table data */
|
||||
|
||||
activity: null, /* table data */
|
||||
disposition_formatter: ConnectionDisposition.formatter,
|
||||
|
||||
/* recent list */
|
||||
set_prefix: usersetting_prefix,
|
||||
recent_senders: UserSettings.get()
|
||||
.get_recent_list(usersetting_prefix + sender_type),
|
||||
|
||||
/* suggestions (from server) */
|
||||
select_list: { suggestions: [] }
|
||||
};
|
||||
},
|
||||
|
||||
activated: function() {
|
||||
const new_email = this.$route.query.email;
|
||||
const new_server = this.$route.query.server;
|
||||
const new_sender_type = new_email ? 'email' :
|
||||
( new_server ? 'server' : null );
|
||||
|
||||
var load = false;
|
||||
if (new_sender_type &&
|
||||
new_sender_type != this.sender_type)
|
||||
{
|
||||
this.sender_type = new_sender_type;
|
||||
load = true;
|
||||
}
|
||||
if (this.sender_type == 'email' &&
|
||||
new_email &&
|
||||
new_email != this.email)
|
||||
{
|
||||
this.email = new_email;
|
||||
this.getChartData();
|
||||
return;
|
||||
}
|
||||
if (this.sender_type == 'server' &&
|
||||
new_server &&
|
||||
new_server != this.server)
|
||||
{
|
||||
this.server = new_server;
|
||||
this.getChartData();
|
||||
return;
|
||||
}
|
||||
|
||||
// see if props changed when deactive
|
||||
if (load || this.date_range &&
|
||||
this.date_range !== this.data_date_range)
|
||||
{
|
||||
this.getChartData();
|
||||
}
|
||||
else
|
||||
{
|
||||
// ensure the route query contains the sender
|
||||
this.update_route();
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
// watch props for changes
|
||||
'date_range': function() {
|
||||
this.getChartData();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
update_recent_list: function() {
|
||||
this.recent_senders = UserSettings.get()
|
||||
.get_recent_list(this.set_prefix + this.sender_type);
|
||||
},
|
||||
|
||||
update_route: function() {
|
||||
// ensure the route contains query element
|
||||
// "email=<data_sender>" or "server=<data_sender>"
|
||||
// for the loaded data
|
||||
if (this.data_sender && this.data_sender !== this.$route.query[this.sender_type]) {
|
||||
var route = Object.assign({}, this.$route);
|
||||
route.query = Object.assign({}, this.$route.query);
|
||||
delete route.query.sender;
|
||||
delete route.query.email;
|
||||
route.query[this.sender_type] = this.data_sender;
|
||||
this.$router.replace(route);
|
||||
}
|
||||
},
|
||||
|
||||
change_sender: function() {
|
||||
axios.post('/reports/uidata/select-list-suggestions', {
|
||||
type: this.sender_type == 'email' ?
|
||||
'envelope_from' : 'remote_host',
|
||||
query: this.sender_type == 'email' ?
|
||||
this.email.trim() : this.server.trim(),
|
||||
start_date: this.date_range[0],
|
||||
end_date: this.date_range[1]
|
||||
}).then(response => {
|
||||
if (response.data.exact) {
|
||||
this.getChartData();
|
||||
}
|
||||
else {
|
||||
this.select_list = response.data;
|
||||
this.$refs.suggest_modal.show()
|
||||
}
|
||||
}).catch(error => {
|
||||
this.$root.handleError(error);
|
||||
});
|
||||
},
|
||||
|
||||
choose_suggestion: function(suggestion) {
|
||||
this[this.sender_type] = suggestion;
|
||||
this.getChartData();
|
||||
this.$refs.suggest_modal.hide();
|
||||
},
|
||||
|
||||
combine_fields: function() {
|
||||
// remove these fields...
|
||||
this.activity
|
||||
.combine_fields([
|
||||
'sent_id',
|
||||
'sasl_username',
|
||||
'spam_score',
|
||||
'dkim_reason',
|
||||
'dmarc_reason',
|
||||
'postgrey_reason',
|
||||
'postgrey_delay',
|
||||
'category',
|
||||
'failure_info',
|
||||
]);
|
||||
},
|
||||
|
||||
get_row_limit: function() {
|
||||
return UserSettings.get().row_limit;
|
||||
},
|
||||
|
||||
update_activity_rowVariant: function() {
|
||||
// there is 1 row for each recipient of a message
|
||||
// - give all rows of the same message the same
|
||||
// color
|
||||
this.activity.apply_rowVariant_grouping('info', (item, idx) => {
|
||||
if (this.show_only_flagged && !item._flagged)
|
||||
return null;
|
||||
return item.sent_id;
|
||||
});
|
||||
},
|
||||
|
||||
show_only_flagged_change: function() {
|
||||
// 'change' event callback for checkbox
|
||||
this.update_activity_rowVariant();
|
||||
// trigger BV to filter or not filter via
|
||||
// reactive `show_only_flagged_filter`
|
||||
this.show_only_flagged_filter=
|
||||
(this.show_only_flagged ? 'yes' : null );
|
||||
},
|
||||
|
||||
table_filter_cb: function(item, filter) {
|
||||
// when filter is non-null, this is called by BV for
|
||||
// each row to determine whether it will be filtered
|
||||
// (false) or included in the output (true)
|
||||
return item._flagged;
|
||||
},
|
||||
|
||||
getChartData: function() {
|
||||
if (!this.date_range || !this[this.sender_type]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('loading', 1);
|
||||
axios.post('reports/uidata/remote-sender-activity', {
|
||||
row_limit: this.get_row_limit(),
|
||||
sender: this[this.sender_type].trim(),
|
||||
sender_type: this.sender_type,
|
||||
start_date: this.date_range[0],
|
||||
end_date: this.date_range[1]
|
||||
|
||||
}).then(response => {
|
||||
this.data_sender = this[this.sender_type].trim();
|
||||
this.data_sender_type = this.sender_type;
|
||||
this.data_date_range = this.date_range;
|
||||
this.update_route();
|
||||
this.recent_senders = UserSettings.get()
|
||||
.add_to_recent_list(
|
||||
this.set_prefix + this.sender_type,
|
||||
this[this.sender_type]
|
||||
);
|
||||
this.show_only_flagged = false;
|
||||
this.show_only_flagged_filter = null;
|
||||
|
||||
/* setup table data */
|
||||
this.activity =
|
||||
new MailBvTable(response.data.activity, {
|
||||
_showDetails: true
|
||||
});
|
||||
this.combine_fields();
|
||||
this.activity
|
||||
.flag_fields()
|
||||
.get_field('connect_time')
|
||||
.add_tdClass('text-nowrap');
|
||||
this.update_activity_rowVariant();
|
||||
|
||||
}).catch(error => {
|
||||
this.$root.handleError(error);
|
||||
|
||||
}).finally(() => {
|
||||
this.$emit('loading', -1);
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
row_clicked: function(item, index, event) {
|
||||
item._showDetails = ! item._showDetails;
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
})}).catch((e) => {
|
||||
reject(e);
|
||||
});
|
||||
|
||||
});
|
||||
75
management/reporting/ui/panel-user-activity.html
Normal file
75
management/reporting/ui/panel-user-activity.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<div>
|
||||
|
||||
<datalist id="panel-ua-users">
|
||||
<option v-for="user in all_users">{{ user }}</option>
|
||||
</datalist>
|
||||
|
||||
<b-form @submit.prevent="getChartData()" class="d-flex">
|
||||
<b-input-group class="mb-3" style="width:30em">
|
||||
<b-form-input class="h-auto" :autofocus="data_user_id===null" list="panel-ua-users" v-model="user_id" placeholder="Enter a user id/email address"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button variant="primary" @click="change_user">Change user</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<b-alert variant="warning" class="ml-2" :show="sent_mail && sent_mail.items.length>=get_row_limit() || received_mail && received_mail.items.length>=get_row_limit()"><sup>*</sup> Tables limited to {{ get_row_limit() }} rows <router-link to="/settings"><b-icon icon="gear-fill"></b-icon></router-link></b-alert>
|
||||
<b-form-checkbox class="ml-auto" v-model="show_only_flagged" @change="show_only_flagged_change()">Flagged only</b-form-checkbox>
|
||||
</b-form>
|
||||
|
||||
<b-tabs content-class="mt2" v-model="tab_index" v-if="sent_mail && received_mail">
|
||||
<b-tab>
|
||||
<template #title>
|
||||
Sent mail<sup v-if="sent_mail.items.length >= get_row_limit()">*</sup> ({{sent_mail.unique_sends}} → {{sent_mail.items.length}})
|
||||
</template>
|
||||
<b-table
|
||||
class="sticky-table-header-0 bg-light"
|
||||
small
|
||||
:filter="show_only_flagged_filter"
|
||||
:filter-function="table_filter_cb"
|
||||
tbody-tr-class="cursor-pointer"
|
||||
details-td-class="cursor-default"
|
||||
@row-clicked="row_clicked"
|
||||
:items="sent_mail.items"
|
||||
:fields="sent_mail.fields">
|
||||
<template #row-details="row">
|
||||
<b-card>
|
||||
<div><strong>Relay</strong>: {{row.item.relay}}</div>
|
||||
<div v-if="row.item.service != 'lmtp'"><strong>Connection</strong>:{{ row.item.delivery_connection_info }}</div>
|
||||
<div><strong>Delivery</strong>: {{row.item.delivery_info}}</div>
|
||||
<div v-if="row.item.spam_result"><strong>Spam score</strong>: {{sent_mail.x_fields.spam_score.formatter(row.item.spam_score)}}</div>
|
||||
</b-card>
|
||||
</template>
|
||||
|
||||
</b-table>
|
||||
</b-tab>
|
||||
|
||||
<b-tab :title="`Received mail (${received_mail.items.length})`">
|
||||
<template #title>
|
||||
Received mail<sup v-if="received_mail.items.length >= get_row_limit()">*</sup> ({{received_mail.items.length}})
|
||||
</template>
|
||||
<b-table
|
||||
class="sticky-table-header-0 bg-light"
|
||||
small
|
||||
:filter="show_only_flagged_filter"
|
||||
:filter-function="table_filter_cb"
|
||||
tbody-tr-class="cursor-pointer"
|
||||
details-td-class="cursor-default"
|
||||
@row-clicked="row_clicked"
|
||||
:items="received_mail.items"
|
||||
:fields="received_mail.fields">
|
||||
<template #cell(envelope_from)='data'>
|
||||
<wbr-text :text="data.value" :text_break_threshold="15"></wbr-text>
|
||||
</template>
|
||||
<template #row-details="row">
|
||||
<b-card>
|
||||
<div><strong>Connection disposition</strong>: {{ disposition_formatter(row.item.disposition) }}</div>
|
||||
<div v-if="row.item.dkim_reason"><strong>Dkim reason</strong>: {{row.item.dkim_reason}}</div>
|
||||
<div v-if="row.item.dmarc_reason"><strong>Dmarc reason</strong>: {{row.item.dmarc_reason}}</div>
|
||||
<div v-if="row.item.postgrey_reason"><strong>Postgrey reason</strong>: {{row.item.postgrey_reason}}</div>
|
||||
<div v-if="row.item.postgrey_delay"><strong>Postgrey delay</strong>: {{received_mail.x_fields.postgrey_delay.formatter(row.item.postgrey_delay)}}</div>
|
||||
<div v-if="row.item.spam_result"><strong>Spam score</strong>: {{received_mail.x_fields.spam_score.formatter(row.item.spam_score)}}</div>
|
||||
</b-card>
|
||||
</template>
|
||||
</b-table>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
</div>
|
||||
260
management/reporting/ui/panel-user-activity.js
Normal file
260
management/reporting/ui/panel-user-activity.js
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
details on the activity of a user
|
||||
*/
|
||||
|
||||
Vue.component('panel-user-activity', function(resolve, reject) {
|
||||
axios.get('reports/ui/panel-user-activity.html').then((response) => { resolve({
|
||||
|
||||
template: response.data,
|
||||
|
||||
props: {
|
||||
date_range: Array, // YYYY-MM-DD strings (UTC)
|
||||
},
|
||||
|
||||
components: {
|
||||
'wbr-text': Vue.component('wbr-text'),
|
||||
},
|
||||
|
||||
data: function() {
|
||||
var start_tab = this.$route.query.tab ?
|
||||
Number(this.$route.query.tab) :
|
||||
0;
|
||||
return {
|
||||
user_id: this.$route.query.user || '', /* v-model */
|
||||
tab_index: start_tab, /* v-model */
|
||||
show_only_flagged: false,
|
||||
show_only_flagged_filter: null,
|
||||
data_user_id: null, /* user_id for active table data */
|
||||
data_date_range: null, /* date range for active table data */
|
||||
sent_mail: null,
|
||||
received_mail: null,
|
||||
all_users: [],
|
||||
disposition_formatter: ConnectionDisposition.formatter,
|
||||
};
|
||||
},
|
||||
|
||||
activated: function() {
|
||||
const new_tab = Number(this.$route.query.tab);
|
||||
const new_user = this.$route.query.user;
|
||||
|
||||
if (new_user && new_user != this.user_id) {
|
||||
this.user_id = new_user;
|
||||
this.getChartData(isNaN(new_tab) ? 0 : new_tab);
|
||||
return;
|
||||
}
|
||||
|
||||
// first time activated...
|
||||
if (this.all_users.length == 0)
|
||||
this.getChartData(new_tab);
|
||||
|
||||
// see if props changed when deactive
|
||||
else if (this.date_range && this.date_range !== this.data_date_range)
|
||||
this.getChartData(new_tab);
|
||||
else {
|
||||
// ensure the route query contains "user=<data_user_id>"
|
||||
if (!isNaN(new_tab)) this.tab_index = new_tab;
|
||||
this.update_route();
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
watch: {
|
||||
// watch props for changes
|
||||
'date_range': function() {
|
||||
this.getChartData();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
update_route: function() {
|
||||
// ensure the route contains query element
|
||||
// "user=<data_user_id>" for the loaded data
|
||||
if (this.data_user_id && this.data_user_id !== this.$route.query.user) {
|
||||
var route = Object.assign({}, this.$route);
|
||||
route.query = Object.assign({}, this.$route.query);
|
||||
route.query.user=this.data_user_id;
|
||||
this.$router.replace(route);
|
||||
}
|
||||
},
|
||||
|
||||
change_user: function() {
|
||||
this.getChartData(0);
|
||||
},
|
||||
|
||||
combine_sent_mail_fields: function() {
|
||||
// remove these fields...
|
||||
this.sent_mail.combine_fields([
|
||||
'sent_id',
|
||||
'spam_score',
|
||||
'delivery_info',
|
||||
'delivery_connection_info',
|
||||
]);
|
||||
|
||||
// combine fields 'envelope_from' and 'rcpt_to'
|
||||
this.sent_mail.combine_fields(
|
||||
'envelope_from',
|
||||
'rcpt_to',
|
||||
(v, key, item) => {
|
||||
if (item.envelope_from == this.data_user_id)
|
||||
return v;
|
||||
return `${v} (FROM: ${item.envelope_from})`;
|
||||
});
|
||||
|
||||
// combine fields 'relay', 'delivery_connection'
|
||||
this.sent_mail.combine_fields(
|
||||
'delivery_connection',
|
||||
'relay',
|
||||
(v, key, item) => {
|
||||
if (item.service == 'lmtp')
|
||||
return '';
|
||||
var s = v.split('[', 1);
|
||||
// remove the ip address
|
||||
v = s[0];
|
||||
if (!item.delivery_connection ||
|
||||
item.delivery_connection == 'trusted' ||
|
||||
item.delivery_connection == 'verified')
|
||||
{
|
||||
return v;
|
||||
}
|
||||
return `${v}: ${item.delivery_connection}`;
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
combine_received_mail_fields: function() {
|
||||
// remove these fields
|
||||
this.received_mail.combine_fields([
|
||||
'dkim_reason',
|
||||
'dmarc_reason',
|
||||
'postgrey_reason',
|
||||
'postgrey_delay',
|
||||
'spam_score'
|
||||
|
||||
]);
|
||||
// combine fields 'envelope_from' and 'sasl_username'
|
||||
var f = this.received_mail.combine_fields(
|
||||
'sasl_username',
|
||||
'envelope_from',
|
||||
(v, key, item) => {
|
||||
if (!item.sasl_username || item.envelope_from == item.sasl_username)
|
||||
return v;
|
||||
return `${v} (${item.sasl_username})`;
|
||||
});
|
||||
f.label = 'Evelope From (user)';
|
||||
},
|
||||
|
||||
get_row_limit: function() {
|
||||
return UserSettings.get().row_limit;
|
||||
},
|
||||
|
||||
update_sent_mail_rowVariant: function() {
|
||||
// there is 1 row for each recipient of a message
|
||||
// - give all rows of the same message the same
|
||||
// color
|
||||
this.sent_mail.apply_rowVariant_grouping('info', item => {
|
||||
if (this.show_only_flagged && !item._flagged)
|
||||
return null;
|
||||
return item.sent_id;
|
||||
});
|
||||
},
|
||||
|
||||
show_only_flagged_change: function() {
|
||||
// 'change' event callback for checkbox
|
||||
this.update_sent_mail_rowVariant();
|
||||
// trigger BV to filter or not filter via
|
||||
// reactive `show_only_flagged_filter`
|
||||
this.show_only_flagged_filter=
|
||||
(this.show_only_flagged ? 'yes' : null );
|
||||
},
|
||||
|
||||
|
||||
table_filter_cb: function(item, filter) {
|
||||
// when filter is non-null, called by BV for each row
|
||||
// to determine whether it will be filtered (false) or
|
||||
// included in the output (true)
|
||||
return item._flagged;
|
||||
},
|
||||
|
||||
|
||||
getChartData: function(switch_to_tab) {
|
||||
if (this.all_users.length == 0) {
|
||||
this.$emit('loading', 1);
|
||||
axios.get('reports/uidata/user-list').then(response => {
|
||||
this.all_users = response.data;
|
||||
|
||||
}).catch(error => {
|
||||
this.$root.handleError(error);
|
||||
|
||||
}).finally(() => {
|
||||
this.$emit('loading', -1);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (!this.date_range || !this.user_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit('loading', 1);
|
||||
const promise = axios.post('reports/uidata/user-activity', {
|
||||
row_limit: this.get_row_limit(),
|
||||
user_id: this.user_id.trim(),
|
||||
start_date: this.date_range[0],
|
||||
end_date: this.date_range[1]
|
||||
|
||||
}).then(response => {
|
||||
|
||||
this.data_user_id = this.user_id.trim();
|
||||
this.data_date_range = this.date_range;
|
||||
if (!isNaN(switch_to_tab))
|
||||
this.tab_index = switch_to_tab;
|
||||
this.update_route();
|
||||
this.$emit('change', this.data_user_id);
|
||||
this.show_only_flagged = false;
|
||||
this.show_only_flagged_filter = null;
|
||||
|
||||
/* setup sent_mail */
|
||||
this.sent_mail = new MailBvTable(
|
||||
response.data.sent_mail, {
|
||||
_showDetails: true
|
||||
});
|
||||
this.combine_sent_mail_fields();
|
||||
this.sent_mail
|
||||
.flag_fields()
|
||||
.get_field('connect_time')
|
||||
.add_tdClass('text-nowrap');
|
||||
this.update_sent_mail_rowVariant();
|
||||
|
||||
|
||||
/* setup received_mail */
|
||||
this.received_mail = new MailBvTable(
|
||||
response.data.received_mail, {
|
||||
_showDetails: true
|
||||
});
|
||||
this.combine_received_mail_fields();
|
||||
this.received_mail
|
||||
.flag_fields()
|
||||
.get_field('connect_time')
|
||||
.add_tdClass('text-nowrap');
|
||||
|
||||
}).catch(error => {
|
||||
this.$root.handleError(error);
|
||||
|
||||
}).finally(() => {
|
||||
this.$emit('loading', -1);
|
||||
});
|
||||
|
||||
return promise;
|
||||
},
|
||||
|
||||
row_clicked: function(item, index, event) {
|
||||
item._showDetails = ! item._showDetails;
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
})}).catch((e) => {
|
||||
reject(e);
|
||||
});
|
||||
|
||||
});
|
||||
24
management/reporting/ui/reports-page-header.js
Normal file
24
management/reporting/ui/reports-page-header.js
Normal file
@@ -0,0 +1,24 @@
|
||||
Vue.component('reports-page-header', {
|
||||
props: {
|
||||
loading_counter: { type:Number, required:true },
|
||||
},
|
||||
|
||||
components: {
|
||||
'page-header': Vue.component('page-header'),
|
||||
},
|
||||
|
||||
template:
|
||||
'<page-header '+
|
||||
'header_text="Server Activity" :loading_counter="loading_counter">'+
|
||||
'<template v-slot:links>'+
|
||||
' <b-navbar type="dark" variant="transparent" class="p-0">'+
|
||||
' <b-navbar-nav>'+
|
||||
' <b-nav-item href="/admin">Admin Panel</b-nav-item>'+
|
||||
' <b-nav-item to="/settings"><b-icon icon="gear-fill" aria-hidden="true"></b-icon></b-nav-item>'+
|
||||
' </b-navbar-nav>'+
|
||||
' </b-navbar>'+
|
||||
'</template>'+
|
||||
'</page-header>'
|
||||
,
|
||||
|
||||
});
|
||||
94
management/reporting/ui/settings.js
Normal file
94
management/reporting/ui/settings.js
Normal file
@@ -0,0 +1,94 @@
|
||||
window.miabldap = window.miabldap || {};
|
||||
|
||||
class CaptureConfig {
|
||||
static get() {
|
||||
return axios.get('/reports/capture/config').then(response => {
|
||||
var cc = new CaptureConfig();
|
||||
Object.assign(cc, response.data);
|
||||
return cc;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
class UserSettings {
|
||||
static load() {
|
||||
if (window.miabldap.user_settings) {
|
||||
return Promise.resolve(window.miabldap.user_settings);
|
||||
}
|
||||
|
||||
var s = new UserSettings();
|
||||
var json = localStorage.getItem('user_settings');
|
||||
if (json) {
|
||||
s.data = JSON.parse(json);
|
||||
}
|
||||
else {
|
||||
s.data = {
|
||||
row_limit: 1000
|
||||
};
|
||||
}
|
||||
window.miabldap.user_settings = s;
|
||||
return Promise.resolve(s);
|
||||
}
|
||||
|
||||
static get() {
|
||||
return window.miabldap.user_settings;
|
||||
}
|
||||
|
||||
save() {
|
||||
var json = JSON.stringify(this.data);
|
||||
localStorage.setItem('user_settings', json);
|
||||
}
|
||||
|
||||
_add_recent(list, val) {
|
||||
var found = -1;
|
||||
list.forEach((str, idx) => {
|
||||
if (str.toLowerCase() == val.toLowerCase()) {
|
||||
found = idx;
|
||||
}
|
||||
});
|
||||
if (found >= 0) {
|
||||
// move it to the top
|
||||
list.splice(found, 1);
|
||||
}
|
||||
list.unshift(val);
|
||||
while (list.length > 10) list.pop();
|
||||
}
|
||||
|
||||
|
||||
/* row limit */
|
||||
get row_limit() {
|
||||
return this.data.row_limit;
|
||||
}
|
||||
|
||||
set row_limit(v) {
|
||||
v = Number(v);
|
||||
if (isNaN(v)) {
|
||||
throw new ValueError("invalid")
|
||||
}
|
||||
else if (v < 5) {
|
||||
throw new ValueError("minimum 5")
|
||||
}
|
||||
this.data.row_limit = v;
|
||||
this.save();
|
||||
return v;
|
||||
}
|
||||
|
||||
get_recent_list(name) {
|
||||
return this.data['recent_' + name];
|
||||
}
|
||||
|
||||
add_to_recent_list(name, value) {
|
||||
const dataname = 'recent_' + name;
|
||||
var v = this.data[dataname];
|
||||
if (! v) {
|
||||
this.data[dataname] = [ value ];
|
||||
this.save();
|
||||
return this.data[dataname];
|
||||
}
|
||||
this._add_recent(v, value);
|
||||
this.data[dataname] = v;
|
||||
this.save();
|
||||
return v;
|
||||
}
|
||||
};
|
||||
48
management/reporting/ui/wbr-text.js
Normal file
48
management/reporting/ui/wbr-text.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* This component adds <wbr> elements after all characters given by
|
||||
* `break_chars` in the given text.
|
||||
*
|
||||
* <wbr> enables the browser to wrap long text at those points
|
||||
* (without it the browser will only wrap at space and hyphen).
|
||||
*
|
||||
* Additionally, if `text_break_threshold` is greater than 0 and there
|
||||
* is a segment of text that exceeds that length, the bootstrap css
|
||||
* class "text-break" will be added to the <span>, which causes the
|
||||
* browser to wrap at any character of the text.
|
||||
*/
|
||||
|
||||
Vue.component('wbr-text', {
|
||||
props: {
|
||||
text: { type:String, required: true },
|
||||
break_chars: { type:String, default:'@_.,:+=' },
|
||||
text_break_threshold: { type:Number, default:0 },
|
||||
},
|
||||
|
||||
render: function(ce) {
|
||||
var children = [];
|
||||
var start=-1;
|
||||
var idx=0;
|
||||
var longest=-1;
|
||||
while (idx < this.text.length) {
|
||||
if (this.break_chars.indexOf(this.text[idx]) != -1) {
|
||||
var sliver = this.text.substring(start+1, idx+1);
|
||||
longest = Math.max(longest, sliver.length);
|
||||
children.push(sliver);
|
||||
children.push(ce('wbr'));
|
||||
start=idx;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (start < this.text.length-1) {
|
||||
var sliver = this.text.substring(start+1);
|
||||
longest = Math.max(longest, sliver.length);
|
||||
children.push(sliver);
|
||||
}
|
||||
|
||||
var data = { };
|
||||
if (this.text_break_threshold>0 && longest>this.text_break_threshold)
|
||||
data['class'] = { 'text-break': true };
|
||||
return ce('span', data, children);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user