1
0
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:
downtownallday
2021-01-11 18:02:07 -05:00
parent 73a2b72243
commit 2a0e50c8d4
108 changed files with 9027 additions and 6 deletions

View 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);
});
},
}
});

View 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");
}
}
}
});

View 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)));
}
}
},
});

View 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;
},
}
});

View 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;
}
}
});

View 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 20182020 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.
*/

View 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;
}
};

View 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();
},
}
});

View 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>

View 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);
});
}

View 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>

View 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);
});
});

View 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>

View 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);
});
});

View 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>

View 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);
});
});

View 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>

View 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);
});
});

View 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>

View 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);
});
});

View 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}} &rarr; {{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>

View 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);
});
});

View 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}} &rarr; {{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>

View 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);
});
});

View 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>'
,
});

View 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;
}
};

View 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);
}
});