xref: /plugin/botmon/script.js (revision 6f3fa739e3c45800ce3f21fc5b62b9516b5287b0)
1"use strict";
2/* DokuWiki BotMon Plugin Script file */
3/* 06.09.2025 - 0.2.0 - beta */
4/* Author: Sascha Leib <ad@hominem.info> */
5
6// enumeration of user types:
7const BM_USERTYPE = Object.freeze({
8	'UNKNOWN': 'unknown',
9	'KNOWN_USER': 'user',
10	'HUMAN': 'human',
11	'LIKELY_BOT': 'likely_bot',
12	'KNOWN_BOT': 'known_bot'
13});
14
15/* BotMon root object */
16const BotMon = {
17
18	init: function() {
19		//console.info('BotMon.init()');
20
21		// find the plugin basedir:
22		this._baseDir = document.currentScript.src.substring(0, document.currentScript.src.indexOf('/exe/'))
23			+ '/plugins/botmon/';
24
25		// read the page language from the DOM:
26		this._lang = document.getRootNode().documentElement.lang || this._lang;
27
28		// get the time offset:
29		this._timeDiff = BotMon.t._getTimeOffset();
30
31		// init the sub-objects:
32		BotMon.t._callInit(this);
33	},
34
35	_baseDir: null,
36	_lang: 'en',
37	_today: (new Date()).toISOString().slice(0, 10),
38	_timeDiff: '',
39
40	/* internal tools */
41	t: {
42		/* helper function to call inits of sub-objects */
43		_callInit: function(obj) {
44			//console.info('BotMon.t._callInit(obj=',obj,')');
45
46			/* call init / _init on each sub-object: */
47			Object.keys(obj).forEach( (key,i) => {
48				const sub = obj[key];
49				let init = null;
50				if (typeof sub === 'object' && sub.init) {
51					init = sub.init;
52				}
53
54				// bind to object
55				if (typeof init == 'function') {
56					const init2 = init.bind(sub);
57					init2(obj);
58				}
59			});
60		},
61
62		/* helper function to calculate the time difference to UTC: */
63		_getTimeOffset: function() {
64			const now = new Date();
65			let offset = now.getTimezoneOffset(); // in minutes
66			const sign = Math.sign(offset); // +1 or -1
67			offset = Math.abs(offset); // always positive
68
69			let hours = 0;
70			while (offset >= 60) {
71				hours += 1;
72				offset -= 60;
73			}
74			return ( hours > 0 ? sign * hours + ' h' : '') + (offset > 0 ? ` ${offset} min` : '');
75		},
76
77		/* helper function to create a new element with all attributes and text content */
78		_makeElement: function(name, atlist = undefined, text = undefined) {
79			var r = null;
80			try {
81				r = document.createElement(name);
82				if (atlist) {
83					for (let attr in atlist) {
84						r.setAttribute(attr, atlist[attr]);
85					}
86				}
87				if (text) {
88					r.textContent = text.toString();
89				}
90			} catch(e) {
91				console.error(e);
92			}
93			return r;
94		},
95
96		/* helper to convert an ip address string to a normalised format: */
97		_ip2Num: function(ip) {
98			if (ip.indexOf(':') > 0) { /* IP6 */
99				return (ip.split(':').map(d => ('0000'+d).slice(-4) ).join(''));
100			} else { /* IP4 */
101				return Number(ip.split('.').map(d => ('000'+d).slice(-3) ).join(''));
102			}
103		},
104
105		/* helper function to format a Date object to show only the time. */
106		/* returns String */
107		_formatTime: function(date) {
108
109			if (date) {
110				return ('0'+date.getHours()).slice(-2) + ':' + ('0'+date.getMinutes()).slice(-2) + ':' + ('0'+date.getSeconds()).slice(-2);
111			} else {
112				return null;
113			}
114
115		},
116
117		/* helper function to show a time difference in seconds or minutes */
118		/* returns String */
119		_formatTimeDiff: function(dateA, dateB) {
120
121			// if the second date is ealier, swap them:
122			if (dateA > dateB) dateB = [dateA, dateA = dateB][0];
123
124			// get the difference in milliseconds:
125			let ms = dateB - dateA;
126
127			if (ms > 50) { /* ignore small time spans */
128				const h = Math.floor((ms / (1000 * 60 * 60)) % 24);
129				const m = Math.floor((ms / (1000 * 60)) % 60);
130				const s = Math.floor((ms / 1000) % 60);
131
132				return ( h>0 ? h + 'h ': '') + ( m>0 ? m + 'm ': '') + ( s>0 ? s + 's': '');
133			}
134
135			return null;
136
137		}
138	}
139};
140
141/* everything specific to the "Today" tab is self-contained in the "live" object: */
142BotMon.live = {
143	init: function() {
144		//console.info('BotMon.live.init()');
145
146		// set the title:
147		const tDiff = '(<abbr title="Coordinated Universal Time">UTC</abbr>' + (BotMon._timeDiff != '' ? `, ${BotMon._timeDiff}` : '' ) + ')';
148		BotMon.live.gui.status.setTitle(`Data for <time datetime=${BotMon._today}>${BotMon._today}</time> ${tDiff}`);
149
150		// init sub-objects:
151		BotMon.t._callInit(this);
152	},
153
154	data: {
155		init: function() {
156			//console.info('BotMon.live.data.init()');
157
158			// call sub-inits:
159			BotMon.t._callInit(this);
160		},
161
162		// this will be called when the known json files are done loading:
163		_dispatch: function(file) {
164			//console.info('BotMon.live.data._dispatch(,',file,')');
165
166			// shortcut to make code more readable:
167			const data = BotMon.live.data;
168
169			// set the flags:
170			switch(file) {
171				case 'rules':
172					data._dispatchRulesLoaded = true;
173					break;
174				case 'bots':
175					data._dispatchBotsLoaded = true;
176					break;
177				case 'clients':
178					data._dispatchClientsLoaded = true;
179					break;
180				case 'platforms':
181					data._dispatchPlatformsLoaded = true;
182					break;
183				default:
184					// ignore
185			}
186
187			// are all the flags set?
188			if (data._dispatchBotsLoaded && data._dispatchClientsLoaded && data._dispatchPlatformsLoaded && data._dispatchRulesLoaded) {
189				// chain the log files loading:
190				BotMon.live.data.loadLogFile('srv', BotMon.live.data._onServerLogLoaded);
191			}
192		},
193		// flags to track which data files have been loaded:
194		_dispatchBotsLoaded: false,
195		_dispatchClientsLoaded: false,
196		_dispatchPlatformsLoaded: false,
197		_dispatchRulesLoaded: false,
198
199		// event callback, after the server log has been loaded:
200		_onServerLogLoaded: function() {
201			//console.info('BotMon.live.data._onServerLogLoaded()');
202
203			// chain the client log file to load:
204			BotMon.live.data.loadLogFile('log', BotMon.live.data._onClientLogLoaded);
205		},
206
207		// event callback, after the client log has been loaded:
208		_onClientLogLoaded: function() {
209			//console.info('BotMon.live.data._onClientLogLoaded()');
210
211			// chain the ticks file to load:
212			BotMon.live.data.loadLogFile('tck', BotMon.live.data._onTicksLogLoaded);
213
214		},
215
216		// event callback, after the tiker log has been loaded:
217		_onTicksLogLoaded: function() {
218			//console.info('BotMon.live.data._onTicksLogLoaded()');
219
220			// analyse the data:
221			BotMon.live.data.analytics.analyseAll();
222
223			// sort the data:
224			// #TODO
225
226			// display the data:
227			BotMon.live.gui.overview.make();
228
229			//console.log(BotMon.live.data.model._visitors);
230
231		},
232
233		model: {
234			// visitors storage:
235			_visitors: [],
236
237			// find an already existing visitor record:
238			findVisitor: function(visitor) {
239				//console.info('BotMon.live.data.model.findVisitor()');
240				//console.log(visitor);
241
242				// shortcut to make code more readable:
243				const model = BotMon.live.data.model;
244
245				const timeout = 60 * 60 * 1000; /* session timeout: One hour */
246
247				// loop over all visitors already registered:
248				for (let i=0; i<model._visitors.length; i++) {
249					const v = model._visitors[i];
250
251					if (visitor._type == BM_USERTYPE.KNOWN_BOT) { /* known bots */
252
253						// bots match when their ID matches:
254						if (v._bot && v._bot.id == visitor._bot.id) {
255							return v;
256						}
257
258					} else if (visitor._type == BM_USERTYPE.KNOWN_USER) { /* registered users */
259
260						// visitors match when their names match:
261						if ( v.usr == visitor.usr
262						&& v.ip == visitor.ip
263						&& v.agent == visitor.agent) {
264							return v;
265						}
266					} else { /* any other visitor */
267
268						if (Math.abs(v._lastSeen - visitor.ts) < timeout) { /* ignore timed out visits */
269							if ( v.id == visitor.id) { /* match the pre-defined IDs */
270								return v;
271							} else if (v.ip == visitor.ip && v.agent == visitor.agent) {
272								if (v.typ !== 'ip') {
273									console.warn(`Visitor ID “${v.id}” not found, using matchin IP + User-Agent instead.`);
274								}
275								return v;
276							}
277						}
278
279					}
280				}
281				return null; // nothing found
282			},
283
284			/* if there is already this visit registered, return the page view item */
285			_getPageView: function(visit, view) {
286
287				// shortcut to make code more readable:
288				const model = BotMon.live.data.model;
289
290				for (let i=0; i<visit._pageViews.length; i++) {
291					const pv = visit._pageViews[i];
292					if (pv.pg == view.pg) {
293						return pv;
294					}
295				}
296				return null; // not found
297			},
298
299			// register a new visitor (or update if already exists)
300			registerVisit: function(nv, type) {
301				//console.info('registerVisit', nv, type);
302
303				// shortcut to make code more readable:
304				const model = BotMon.live.data.model;
305
306				// is it a known bot?
307				const bot = BotMon.live.data.bots.match(nv.agent);
308
309				// enrich new visitor with relevant data:
310				if (!nv._bot) nv._bot = bot ?? null; // bot info
311				nv._type = ( bot ? BM_USERTYPE.KNOWN_BOT : ( nv.usr && nv.usr !== '' ? BM_USERTYPE.KNOWN_USER : BM_USERTYPE.UNKNOWN ) );
312				if (!nv._firstSeen) nv._firstSeen = nv.ts;
313				nv._lastSeen = nv.ts;
314
315				// check if it already exists:
316				let visitor = model.findVisitor(nv);
317				if (!visitor) {
318					visitor = nv;
319					visitor._seenBy = [type];
320					visitor._pageViews = []; // array of page views
321					visitor._hasReferrer = false; // has at least one referrer
322					visitor._jsClient = false; // visitor has been seen logged by client js as well
323					visitor._client = BotMon.live.data.clients.match(nv.agent) ?? null; // client info
324					visitor._platform = BotMon.live.data.platforms.match(nv.agent); // platform info
325					model._visitors.push(visitor);
326				} else { // update existing
327					if (visitor._firstSeen > nv.ts) {
328						visitor._firstSeen = nv.ts;
329					}
330				}
331
332				// find browser
333
334				// is this visit already registered?
335				let prereg = model._getPageView(visitor, nv);
336				if (!prereg) {
337					// add new page view:
338					prereg = model._makePageView(nv, type);
339					visitor._pageViews.push(prereg);
340				} else {
341					// update last seen date
342					prereg._lastSeen = nv.ts;
343					// increase view count:
344					prereg._viewCount += 1;
345					prereg._tickCount += 1;
346				}
347
348				// update referrer state:
349				visitor._hasReferrer = visitor._hasReferrer ||
350					(prereg.ref !== undefined && prereg.ref !== '');
351
352				// update time stamp for last-seen:
353				if (visitor._lastSeen < nv.ts) {
354					visitor._lastSeen = nv.ts;
355				}
356
357				// if needed:
358				return visitor;
359			},
360
361			// updating visit data from the client-side log:
362			updateVisit: function(dat) {
363				//console.info('updateVisit', dat);
364
365				// shortcut to make code more readable:
366				const model = BotMon.live.data.model;
367
368				const type = 'log';
369
370				let visitor = BotMon.live.data.model.findVisitor(dat);
371				if (!visitor) {
372					visitor = model.registerVisit(dat, type);
373				}
374				if (visitor) {
375
376					if (visitor._lastSeen < dat.ts) {
377						visitor._lastSeen = dat.ts;
378					}
379					if (!visitor._seenBy.includes(type)) {
380						visitor._seenBy.push(type);
381					}
382					visitor._jsClient = true; // seen by client js
383				}
384
385				// find the page view:
386				let prereg = BotMon.live.data.model._getPageView(visitor, dat);
387				if (prereg) {
388					// update the page view:
389					prereg._lastSeen = dat.ts;
390					if (!prereg._seenBy.includes(type)) prereg._seenBy.push(type);
391					prereg._jsClient = true; // seen by client js
392				} else {
393					// add the page view to the visitor:
394					prereg = model._makePageView(dat, type);
395					visitor._pageViews.push(prereg);
396				}
397				prereg._tickCount += 1;
398			},
399
400			// updating visit data from the ticker log:
401			updateTicks: function(dat) {
402				//console.info('updateTicks', dat);
403
404				// shortcut to make code more readable:
405				const model = BotMon.live.data.model;
406
407				const type = 'tck';
408
409				// find the visit info:
410				let visitor = model.findVisitor(dat);
411				if (!visitor) {
412					console.info(`No visitor with ID “${dat.id}” found, registering as a new one.`);
413					visitor = model.registerVisit(dat, type);
414				}
415				if (visitor) {
416					// update visitor:
417					if (visitor._lastSeen < dat.ts) visitor._lastSeen = dat.ts;
418					if (!visitor._seenBy.includes(type)) visitor._seenBy.push(type);
419
420					// get the page view info:
421					let pv = model._getPageView(visitor, dat);
422					if (!pv) {
423						console.warn(`No page view for visit ID “${dat.id}”, page “${dat.pg}”, registering a new one.`);
424						pv = model._makePageView(dat, type);
425						visitor._pageViews.push(pv);
426					}
427
428					// update the page view info:
429					if (!pv._seenBy.includes(type)) pv._seenBy.push(type);
430					if (pv._lastSeen.getTime() < dat.ts.getTime()) pv._lastSeen = dat.ts;
431					pv._tickCount += 1;
432
433				}
434			},
435
436			// helper function to create a new "page view" item:
437			_makePageView: function(data, type) {
438
439				// try to parse the referrer:
440				let rUrl = null;
441				try {
442					rUrl = ( data.ref && data.ref !== '' ? new URL(data.ref) : null );
443				} catch (e) {
444					console.info(`Invalid referer: “${data.ref}”.`);
445				}
446
447				return {
448					_by: type,
449					ip: data.ip,
450					pg: data.pg,
451					_ref: rUrl,
452					_firstSeen: data.ts,
453					_lastSeen: data.ts,
454					_seenBy: [type],
455					_jsClient: ( type !== 'srv'),
456					_viewCount: 1,
457					_tickCount: 0
458				};
459			}
460		},
461
462		analytics: {
463
464			init: function() {
465				//console.info('BotMon.live.data.analytics.init()');
466			},
467
468			// data storage:
469			data: {
470				totalVisits: 0,
471				totalPageViews: 0,
472				bots: {
473					known: 0,
474					suspected: 0,
475					human: 0,
476					users: 0
477				}
478			},
479
480			// sort the visits by type:
481			groups: {
482				knownBots: [],
483				suspectedBots: [],
484				humans: [],
485				users: []
486			},
487
488			// all analytics
489			analyseAll: function() {
490				//console.info('BotMon.live.data.analytics.analyseAll()');
491
492				// shortcut to make code more readable:
493				const model = BotMon.live.data.model;
494				const me = BotMon.live.data.analytics;
495
496				BotMon.live.gui.status.showBusy("Analysing data …");
497
498				// loop over all visitors:
499				model._visitors.forEach( (v) => {
500
501					// count visits and page views:
502					this.data.totalVisits += 1;
503					this.data.totalPageViews += v._pageViews.length;
504
505					// check for typical bot aspects:
506					let botScore = 0;
507
508					if (v._type == BM_USERTYPE.KNOWN_BOT) { // known bots
509
510						this.data.bots.known += v._pageViews.length;
511						this.groups.knownBots.push(v);
512
513					} else if (v._type == BM_USERTYPE.KNOWN_USER) { // known users */
514
515						this.data.bots.users += v._pageViews.length;
516						this.groups.users.push(v);
517
518					} else {
519
520						// get evaluation:
521						const e = BotMon.live.data.rules.evaluate(v);
522						v._eval = e.rules;
523						v._botVal = e.val;
524
525						// add each page view to IP range information (unless it is already from a known bot IP range):
526						v._pageViews.forEach( pv => {
527							me._addToIPRanges(pv.ip);
528						});
529
530						if (e.isBot) { // likely bots
531							v._type = BM_USERTYPE.LIKELY_BOT;
532							this.data.bots.suspected += v._pageViews.length;
533							this.groups.suspectedBots.push(v);
534						} else { // probably humans
535							v._type = BM_USERTYPE.HUMAN;
536							this.data.bots.human += v._pageViews.length;
537							this.groups.humans.push(v);
538						}
539						// TODO: find suspected bots
540
541					}
542				});
543
544				// clean up the ip ranges:
545				me._cleanIPRanges();
546				console.log(BotMon.live.data.analytics._ipRange);
547
548				BotMon.live.gui.status.hideBusy('Done.');
549			},
550
551			// visits from IP ranges:
552			_ipRange: {
553				ip4: [],
554				ip6: []
555			},
556			/**
557			 * Adds a visit to the IP range statistics.
558			 *
559			 * This helps to identify IP ranges that are used by bots.
560			 *
561			 * @param {string} ip The IP address to add.
562			 */
563			_addToIPRanges: function(ip) {
564
565				const me = BotMon.live.data.analytics;
566				const ipv = (ip.indexOf(':') > 0 ? 6 : 4);
567
568				const ipArr = ip.split( ipv == 6 ? ':' : '.');
569				const maxSegments = (ipv == 6 ? 4 : 3);
570
571				let arr = (ipv == 6 ? me._ipRange.ip6 : me._ipRange.ip4);
572
573				// find any existing segment entry:
574				it = null;
575				for (let i=0; i < arr.length; i++) {
576					const sig = arr[i];
577					if (sig.seg == ipArr[0]) {
578						it = sig;
579						break;
580					}
581				}
582
583				// create if not found:
584				if (!it) {
585					it = {seg: ipArr[0], count: 1};
586					//if (i<maxSegments) it.sub = [];
587					arr.push(it);
588
589				} else { // increase count:
590
591					it.count += 1;
592				}
593
594			},
595			_cleanIPRanges: function() {
596				const me = BotMon.live.data.analytics;
597
598				for (const [n, arr] of Object.entries(me._ipRange)) {
599
600					arr.forEach( (it, i) => {
601						if (it.count <= 1) arr.splice(i, 1);
602					});
603
604				};
605			}
606		},
607
608		bots: {
609			// loads the list of known bots from a JSON file:
610			init: async function() {
611				//console.info('BotMon.live.data.bots.init()');
612
613				// Load the list of known bots:
614				BotMon.live.gui.status.showBusy("Loading known bots …");
615				const url = BotMon._baseDir + 'data/known-bots.json';
616				try {
617					const response = await fetch(url);
618					if (!response.ok) {
619						throw new Error(`${response.status} ${response.statusText}`);
620					}
621
622					this._list = await response.json();
623					this._ready = true;
624
625				} catch (error) {
626					BotMon.live.gui.status.setError("Error while loading the ‘known bots’ file: " + error.message);
627				} finally {
628					BotMon.live.gui.status.hideBusy("Status: Done.");
629					BotMon.live.data._dispatch('bots')
630				}
631			},
632
633			// returns bot info if the clientId matches a known bot, null otherwise:
634			match: function(agent) {
635				//console.info('BotMon.live.data.bots.match(',agent,')');
636
637				const BotList = BotMon.live.data.bots._list;
638
639				// default is: not found!
640				let botInfo = null;
641
642				if (!agent) return null;
643
644				// check for known bots:
645				BotList.find(bot => {
646					let r = false;
647					for (let j=0; j<bot.rx.length; j++) {
648						const rxr = agent.match(new RegExp(bot.rx[j]));
649						if (rxr) {
650							botInfo = {
651								n : bot.n,
652								id: bot.id,
653								url: bot.url,
654								v: (rxr.length > 1 ? rxr[1] : -1)
655							};
656							r = true;
657							break;
658						};
659					};
660					return r;
661				});
662
663				// check for unknown bots:
664				if (!botInfo) {
665					const botmatch = agent.match(/[^\s](\w*bot)[\/\s;\),$]/i);
666					if(botmatch) {
667						botInfo = {'id': "other", 'n': "Other", "bot": botmatch[0] };
668					}
669				}
670
671				//console.log("botInfo:", botInfo);
672				return botInfo;
673			},
674
675
676			// indicates if the list is loaded and ready to use:
677			_ready: false,
678
679			// the actual bot list is stored here:
680			_list: []
681		},
682
683		clients: {
684			// loads the list of known clients from a JSON file:
685			init: async function() {
686				//console.info('BotMon.live.data.clients.init()');
687
688				// Load the list of known bots:
689				BotMon.live.gui.status.showBusy("Loading known clients");
690				const url = BotMon._baseDir + 'data/known-clients.json';
691				try {
692					const response = await fetch(url);
693					if (!response.ok) {
694						throw new Error(`${response.status} ${response.statusText}`);
695					}
696
697					BotMon.live.data.clients._list = await response.json();
698					BotMon.live.data.clients._ready = true;
699
700				} catch (error) {
701					BotMon.live.gui.status.setError("Error while loading the known clients file: " + error.message);
702				} finally {
703					BotMon.live.gui.status.hideBusy("Status: Done.");
704					BotMon.live.data._dispatch('clients')
705				}
706			},
707
708			// returns bot info if the user-agent matches a known bot, null otherwise:
709			match: function(agent) {
710				//console.info('BotMon.live.data.clients.match(',agent,')');
711
712				let match = {"n": "Unknown", "v": -1, "id": null};
713
714				if (agent) {
715					BotMon.live.data.clients._list.find(client => {
716						let r = false;
717						for (let j=0; j<client.rx.length; j++) {
718							const rxr = agent.match(new RegExp(client.rx[j]));
719							if (rxr) {
720								match.n = client.n;
721								match.v = (rxr.length > 1 ? rxr[1] : -1);
722								match.id = client.id || null;
723								r = true;
724								break;
725							}
726						}
727						return r;
728					});
729				}
730
731				//console.log(match)
732				return match;
733			},
734
735			// indicates if the list is loaded and ready to use:
736			_ready: false,
737
738			// the actual bot list is stored here:
739			_list: []
740
741		},
742
743		platforms: {
744			// loads the list of known platforms from a JSON file:
745			init: async function() {
746				//console.info('BotMon.live.data.platforms.init()');
747
748				// Load the list of known bots:
749				BotMon.live.gui.status.showBusy("Loading known platforms");
750				const url = BotMon._baseDir + 'data/known-platforms.json';
751				try {
752					const response = await fetch(url);
753					if (!response.ok) {
754						throw new Error(`${response.status} ${response.statusText}`);
755					}
756
757					BotMon.live.data.platforms._list = await response.json();
758					BotMon.live.data.platforms._ready = true;
759
760				} catch (error) {
761					BotMon.live.gui.status.setError("Error while loading the known platforms file: " + error.message);
762				} finally {
763					BotMon.live.gui.status.hideBusy("Status: Done.");
764					BotMon.live.data._dispatch('platforms')
765				}
766			},
767
768			// returns bot info if the browser id matches a known platform:
769			match: function(cid) {
770				//console.info('BotMon.live.data.platforms.match(',cid,')');
771
772				let match = {"n": "Unknown", "id": null};
773
774				if (cid) {
775					BotMon.live.data.platforms._list.find(platform => {
776						let r = false;
777						for (let j=0; j<platform.rx.length; j++) {
778							const rxr = cid.match(new RegExp(platform.rx[j]));
779							if (rxr) {
780								match.n = platform.n;
781								match.v = (rxr.length > 1 ? rxr[1] : -1);
782								match.id = platform.id || null;
783								r = true;
784								break;
785							}
786						}
787						return r;
788					});
789				}
790
791				return match;
792			},
793
794			// indicates if the list is loaded and ready to use:
795			_ready: false,
796
797			// the actual bot list is stored here:
798			_list: []
799
800		},
801
802		rules: {
803			// loads the list of rules and settings from a JSON file:
804			init: async function() {
805				//console.info('BotMon.live.data.rules.init()');
806
807				// Load the list of known bots:
808				BotMon.live.gui.status.showBusy("Loading list of rules …");
809				const url = BotMon._baseDir + 'data/rules.json';
810				try {
811					const response = await fetch(url);
812					if (!response.ok) {
813						throw new Error(`${response.status} ${response.statusText}`);
814					}
815
816					const json = await response.json();
817
818					if (json.rules) {
819						this._rulesList = json.rules;
820					}
821
822					if (json.threshold) {
823						this._threshold = json.threshold;
824					}
825
826					if (json.ipRanges) {
827						// clean up the IPs first:
828						let list = [];
829						json.ipRanges.forEach( it => {
830							let item = {
831								'from': BotMon.t._ip2Num(it.from),
832								'to': BotMon.t._ip2Num(it.to),
833								'label': it.label
834							};
835							list.push(item);
836						});
837
838						this._botIPs = list;
839					}
840
841					this._ready = true;
842
843				} catch (error) {
844					BotMon.live.gui.status.setError("Error while loading the ‘rules’ file: " + error.message);
845				} finally {
846					BotMon.live.gui.status.hideBusy("Status: Done.");
847					BotMon.live.data._dispatch('rules')
848				}
849			},
850
851			_rulesList: [], // list of rules to find out if a visitor is a bot
852			_threshold: 100, // above this, it is considered a bot.
853
854			// returns a descriptive text for a rule id
855			getRuleInfo: function(ruleId) {
856				// console.info('getRuleInfo', ruleId);
857
858				// shortcut for neater code:
859				const me = BotMon.live.data.rules;
860
861				for (let i=0; i<me._rulesList.length; i++) {
862					const rule = me._rulesList[i];
863					if (rule.id == ruleId) {
864						return rule;
865					}
866				}
867				return null;
868
869			},
870
871			// evaluate a visitor for lkikelihood of being a bot
872			evaluate: function(visitor) {
873
874				// shortcut for neater code:
875				const me = BotMon.live.data.rules;
876
877				let r =  {	// evaluation result
878					'val': 0,
879					'rules': [],
880					'isBot': false
881				};
882
883				for (let i=0; i<me._rulesList.length; i++) {
884					const rule = me._rulesList[i];
885					const params = ( rule.params ? rule.params : [] );
886
887					if (rule.func) { // rule is calling a function
888						if (me.func[rule.func]) {
889							if(me.func[rule.func](visitor, ...params)) {
890								r.val += rule.bot;
891								r.rules.push(rule.id)
892							}
893						} else {
894							//console.warn("Unknown rule function: “${rule.func}”. Ignoring rule.")
895						}
896					}
897				}
898
899				// is a bot?
900				r.isBot = (r.val >= me._threshold);
901
902				return r;
903			},
904
905			// list of functions that can be called by the rules list to evaluate a visitor:
906			func: {
907
908				// check if client is on the list passed as parameter:
909				matchesClient: function(visitor, ...clients) {
910
911					const clientId = ( visitor._client ? visitor._client.id : '');
912					return clients.includes(clientId);
913				},
914
915				// check if OS/Platform is one of the obsolete ones:
916				matchesPlatform: function(visitor, ...platforms) {
917
918					const pId = ( visitor._platform ? visitor._platform.id : '');
919					return platforms.includes(pId);
920				},
921
922				// are there at lest num pages loaded?
923				smallPageCount: function(visitor, num) {
924					return (visitor._pageViews.length <= Number(num));
925				},
926
927				// There was no entry in a specific log file for this visitor:
928				// note that this will also trigger the "noJavaScript" rule:
929				noRecord: function(visitor, type) {
930					return !visitor._seenBy.includes(type);
931				},
932
933				// there are no referrers in any of the page visits:
934				noReferrer: function(visitor) {
935
936					let r = false; // return value
937					for (let i = 0; i < visitor._pageViews.length; i++) {
938						if (!visitor._pageViews[i]._ref) {
939							r = true;
940							break;
941						}
942					}
943					return r;
944				},
945
946				// test for specific client identifiers:
947				/*matchesClients: function(visitor, ...list) {
948
949					for (let i=0; i<list.length; i++) {
950						if (visitor._client.id == list[i]) {
951								return true
952						}
953					};
954					return false;
955				},*/
956
957				// unusual combinations of Platform and Client:
958				combinationTest: function(visitor, ...combinations) {
959
960					for (let i=0; i<combinations.length; i++) {
961
962						if (visitor._platform.id == combinations[i][0]
963							&& visitor._client.id == combinations[i][1]) {
964								return true
965						}
966					};
967
968					return false;
969				},
970
971				// is the IP address from a known bot network?
972				fromKnownBotIP: function(visitor) {
973
974					const ipInfo = BotMon.live.data.rules.getBotIPInfo(visitor.ip);
975
976					if (ipInfo) {
977						visitor._ipInKnownBotRange = true;
978					}
979
980					return (ipInfo !== null);
981				},
982
983				// is the page language mentioned in the client's accepted languages?
984				// the parameter holds an array of exceptions, i.e. page languages that should be ignored.
985				matchLang: function(visitor, ...exceptions) {
986
987					if (visitor.lang && visitor.accept && exceptions.indexOf(visitor.lang) >= 0) {
988						return visitor.accept.split(',').indexOf(visitor.lang) >= 0;
989					}
990					return false;
991				},
992
993				// At least x page views were recorded, but they come within less than y seconds
994				loadSpeed: function(visitor, minItems, maxTime) {
995
996					if (visitor._pageViews.length >= minItems) {
997						//console.log('loadSpeed', visitor._pageViews.length, minItems, maxTime);
998
999						const pvArr = visitor._pageViews.map(pv => pv._lastSeen).sort();
1000
1001						let totalTime = 0;
1002						for (let i=1; i < pvArr.length; i++) {
1003							totalTime += (pvArr[i] - pvArr[i-1]);
1004						}
1005
1006						//console.log('     ', totalTime , Math.round(totalTime / (pvArr.length * 1000)), (( totalTime / pvArr.length ) <= maxTime * 1000), visitor.ip);
1007
1008						return (( totalTime / pvArr.length ) <= maxTime * 1000);
1009					}
1010				}
1011			},
1012
1013			/* known bot IP ranges: */
1014			_botIPs: [],
1015
1016			// return information on a bot IP range:
1017			getBotIPInfo: function(ip) {
1018
1019				// shortcut to make code more readable:
1020				const me = BotMon.live.data.rules;
1021
1022				// convert IP address to easier comparable form:
1023				const ipNum = BotMon.t._ip2Num(ip);
1024
1025				for (let i=0; i < me._botIPs.length; i++) {
1026					const ipRange = me._botIPs[i];
1027
1028					if (ipNum >= ipRange.from && ipNum <= ipRange.to) {
1029						return ipRange;
1030					}
1031
1032				};
1033				return null;
1034
1035			}
1036
1037		},
1038
1039		loadLogFile: async function(type, onLoaded = undefined) {
1040			//console.info('BotMon.live.data.loadLogFile(',type,')');
1041
1042			let typeName = '';
1043			let columns = [];
1044
1045			switch (type) {
1046				case "srv":
1047					typeName = "Server";
1048					columns = ['ts','ip','pg','id','typ','usr','agent','ref','lang','accept'];
1049					break;
1050				case "log":
1051					typeName = "Page load";
1052					columns = ['ts','ip','pg','id','usr','lt','ref','agent'];
1053					break;
1054				case "tck":
1055					typeName = "Ticker";
1056					columns = ['ts','ip','pg','id','agent'];
1057					break;
1058				default:
1059					console.warn(`Unknown log type ${type}.`);
1060					return;
1061			}
1062
1063			// Show the busy indicator and set the visible status:
1064			BotMon.live.gui.status.showBusy(`Loading ${typeName} log file …`);
1065
1066			// compose the URL from which to load:
1067			const url = BotMon._baseDir + `logs/${BotMon._today}.${type}.txt`;
1068			//console.log("Loading:",url);
1069
1070			// fetch the data:
1071			try {
1072				const response = await fetch(url);
1073				if (!response.ok) {
1074					throw new Error(`${response.status} ${response.statusText}`);
1075				}
1076
1077				const logtxt = await response.text();
1078
1079				logtxt.split('\n').forEach((line) => {
1080					if (line.trim() === '') return; // skip empty lines
1081					const cols = line.split('\t');
1082
1083					// assign the columns to an object:
1084					const data = {};
1085					cols.forEach( (colVal,i) => {
1086						colName = columns[i] || `col${i}`;
1087						const colValue = (colName == 'ts' ? new Date(colVal) : colVal.trim());
1088						data[colName] = colValue;
1089					});
1090
1091					// register the visit in the model:
1092					switch(type) {
1093						case 'srv':
1094							BotMon.live.data.model.registerVisit(data, type);
1095							break;
1096						case 'log':
1097							data.typ = 'js';
1098							BotMon.live.data.model.updateVisit(data);
1099							break;
1100						case 'tck':
1101							data.typ = 'js';
1102							BotMon.live.data.model.updateTicks(data);
1103							break;
1104						default:
1105							console.warn(`Unknown log type ${type}.`);
1106							return;
1107					}
1108				});
1109
1110				if (onLoaded) {
1111					onLoaded(); // callback after loading is finished.
1112				}
1113
1114			} catch (error) {
1115				BotMon.live.gui.status.setError(`Error while loading the ${typeName} log file: ${error.message}.`);
1116			} finally {
1117				BotMon.live.gui.status.hideBusy("Status: Done.");
1118			}
1119		}
1120	},
1121
1122	gui: {
1123		init: function() {
1124			// init the lists view:
1125			this.lists.init();
1126		},
1127
1128		overview: {
1129			make: function() {
1130
1131				const data = BotMon.live.data.analytics.data;
1132				const parent = document.getElementById('botmon__today__content');
1133
1134				// shortcut for neater code:
1135				const makeElement = BotMon.t._makeElement;
1136
1137				if (parent) {
1138
1139					const bounceRate = Math.round(data.totalVisits / data.totalPageViews * 100);
1140
1141					jQuery(parent).prepend(jQuery(`
1142						<details id="botmon__today__overview" open>
1143							<summary>Overview</summary>
1144							<div class="grid-3-columns">
1145								<dl>
1146									<dt>Web metrics</dt>
1147									<dd><span>Total page views:</span><strong>${data.totalPageViews}</strong></dd>
1148									<dd><span>Total visitors (est.):</span><span>${data.totalVisits}</span></dd>
1149									<dd><span>Bounce rate (est.):</span><span>${bounceRate}%</span></dd>
1150								</dl>
1151								<dl>
1152									<dt>Bots vs. Humans (page views)</dt>
1153									<dd><span>Registered users:</span><strong>${data.bots.users}</strong></dd>
1154									<dd><span>Probably humans:</span><strong>${data.bots.human}</strong></dd>
1155									<dd><span>Suspected bots:</span><strong>${data.bots.suspected}</strong></dd>
1156									<dd><span>Known bots:</span><strong>${data.bots.known}</strong></dd>
1157								</dl>
1158								<dl id="botmon__botslist"></dl>
1159							</div>
1160						</details>
1161					`));
1162
1163					// update known bots list:
1164					const block = document.getElementById('botmon__botslist');
1165					block.innerHTML = "<dt>Top known bots (page views)</dt>";
1166
1167					let bots = BotMon.live.data.analytics.groups.knownBots.toSorted( (a, b) => {
1168						return b._pageViews.length - a._pageViews.length;
1169					});
1170
1171					for (let i=0; i < Math.min(bots.length, 4); i++) {
1172						const dd = makeElement('dd');
1173						dd.appendChild(makeElement('span', {'class': 'bot bot_' + bots[i]._bot.id }, bots[i]._bot.n));
1174						dd.appendChild(makeElement('strong', undefined, bots[i]._pageViews.length));
1175						block.appendChild(dd);
1176					}
1177				}
1178			}
1179		},
1180
1181		status: {
1182			setText: function(txt) {
1183				const el = document.getElementById('botmon__today__status');
1184				if (el && BotMon.live.gui.status._errorCount <= 0) {
1185					el.innerText = txt;
1186				}
1187			},
1188
1189			setTitle: function(html) {
1190				const el = document.getElementById('botmon__today__title');
1191				if (el) {
1192					el.innerHTML = html;
1193				}
1194			},
1195
1196			setError: function(txt) {
1197				console.error(txt);
1198				BotMon.live.gui.status._errorCount += 1;
1199				const el = document.getElementById('botmon__today__status');
1200				if (el) {
1201					el.innerText = "An error occured. See the browser log for details!";
1202					el.classList.add('error');
1203				}
1204			},
1205			_errorCount: 0,
1206
1207			showBusy: function(txt = null) {
1208				BotMon.live.gui.status._busyCount += 1;
1209				const el = document.getElementById('botmon__today__busy');
1210				if (el) {
1211					el.style.display = 'inline-block';
1212				}
1213				if (txt) BotMon.live.gui.status.setText(txt);
1214			},
1215			_busyCount: 0,
1216
1217			hideBusy: function(txt = null) {
1218				const el = document.getElementById('botmon__today__busy');
1219				BotMon.live.gui.status._busyCount -= 1;
1220				if (BotMon.live.gui.status._busyCount <= 0) {
1221					if (el) el.style.display = 'none';
1222					if (txt) BotMon.live.gui.status.setText(txt);
1223				}
1224			}
1225		},
1226
1227		lists: {
1228			init: function() {
1229
1230				// function shortcut:
1231				const makeElement = BotMon.t._makeElement;
1232
1233				const parent = document.getElementById('botmon__today__visitorlists');
1234				if (parent) {
1235
1236					for (let i=0; i < 4; i++) {
1237
1238						// change the id and title by number:
1239						let listTitle = '';
1240						let listId = '';
1241						switch (i) {
1242							case 0:
1243								listTitle = "Registered users";
1244								listId = 'users';
1245								break;
1246							case 1:
1247								listTitle = "Probably humans";
1248								listId = 'humans';
1249								break;
1250							case 2:
1251								listTitle = "Suspected bots";
1252								listId = 'suspectedBots';
1253								break;
1254							case 3:
1255								listTitle = "Known bots";
1256								listId = 'knownBots';
1257								break;
1258							default:
1259								console.warn('Unknown list number.');
1260						}
1261
1262						const details = makeElement('details', {
1263							'data-group': listId,
1264							'data-loaded': false
1265						});
1266						const title = details.appendChild(makeElement('summary'));
1267						title.appendChild(makeElement('span', {'class':'title'}, listTitle));
1268						title.appendChild(makeElement('span', {'class':'counter'}, '–'));
1269						details.addEventListener("toggle", this._onDetailsToggle);
1270
1271						parent.appendChild(details);
1272
1273					}
1274				}
1275			},
1276
1277			_onDetailsToggle: function(e) {
1278				//console.info('BotMon.live.gui.lists._onDetailsToggle()');
1279
1280				const target = e.target;
1281
1282				if (target.getAttribute('data-loaded') == 'false') { // only if not loaded yet
1283					target.setAttribute('data-loaded', 'loading');
1284
1285					const fillType = target.getAttribute('data-group');
1286					const fillList = BotMon.live.data.analytics.groups[fillType];
1287					if (fillList && fillList.length > 0) {
1288
1289						const ul = BotMon.t._makeElement('ul');
1290
1291						fillList.forEach( (it) => {
1292							ul.appendChild(BotMon.live.gui.lists._makeVisitorItem(it, fillType));
1293						});
1294
1295						target.appendChild(ul);
1296						target.setAttribute('data-loaded', 'true');
1297					} else {
1298						target.setAttribute('data-loaded', 'false');
1299					}
1300
1301				}
1302			},
1303
1304			_makeVisitorItem: function(data, type) {
1305
1306				// shortcut for neater code:
1307				const make = BotMon.t._makeElement;
1308
1309				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
1310				const platformName = (data._platform ? data._platform.n : 'Unknown');
1311				const clientName = (data._client ? data._client.n: 'Unknown');
1312
1313				const li = make('li'); // root list item
1314				const details = make('details');
1315				const summary = make('summary');
1316				details.appendChild(summary);
1317
1318				const span1 = make('span'); /* left-hand group */
1319				if (data._type == BM_USERTYPE.KNOWN_BOT) { /* Bot only */
1320
1321					const botName = ( data._bot && data._bot.n ? data._bot.n : "Unknown");
1322					span1.appendChild(make('span', { /* Bot */
1323						'class': 'bot bot_' + (data._bot ? data._bot.id : 'unknown'),
1324						'title': "Bot: " + botName
1325					}, botName));
1326
1327				} else if (data._type == BM_USERTYPE.KNOWN_USER) { /* User only */
1328
1329					span1.appendChild(make('span', { /* User */
1330						'class': 'user_known',
1331						'title': "User: " + data.usr
1332					}, data.usr));
1333
1334				} else { /* others */
1335
1336					if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
1337					span1.appendChild(make('span', { /* IP-Address */
1338						'class': 'ipaddr ip' + ipType,
1339						'title': "IP-Address: " + data.ip
1340					}, data.ip));
1341
1342				}
1343
1344				if (data._type !== BM_USERTYPE.KNOWN_BOT) { /* Not for bots */
1345					span1.appendChild(make('span', { /* Platform */
1346						'class': 'icon platform platform_' + (data._platform ? data._platform.id : 'unknown'),
1347						'title': "Platform: " + platformName
1348					}, platformName));
1349
1350					span1.appendChild(make('span', { /* Client */
1351						'class': 'icon client client_' + (data._client ? data._client.id : 'unknown'),
1352						'title': "Client: " + clientName
1353					}, clientName));
1354				}
1355
1356				summary.appendChild(span1);
1357				const span2 = make('span'); /* right-hand group */
1358
1359				span2.appendChild(make('span', { /* page views */
1360					'class': 'pageviews'
1361				}, data._pageViews.length));
1362
1363				summary.appendChild(span2);
1364
1365				// add details expandable section:
1366				details.appendChild(BotMon.live.gui.lists._makeVisitorDetails(data, type));
1367
1368				li.appendChild(details);
1369				return li;
1370			},
1371
1372			_makeVisitorDetails: function(data, type) {
1373
1374				// shortcut for neater code:
1375				const make = BotMon.t._makeElement;
1376
1377				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
1378				if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
1379				const platformName = (data._platform ? data._platform.n : 'Unknown');
1380				const clientName = (data._client ? data._client.n: 'Unknown');
1381
1382				const dl = make('dl', {'class': 'visitor_details'});
1383
1384				if (data._type == BM_USERTYPE.KNOWN_BOT) {
1385
1386					dl.appendChild(make('dt', {}, "Bot name:")); /* bot info */
1387					dl.appendChild(make('dd', {'class': 'has_icon bot bot_' + (data._bot ? data._bot.id : 'unknown')},
1388						(data._bot ? data._bot.n : 'Unknown')));
1389
1390					if (data._bot && data._bot.url) {
1391						dl.appendChild(make('dt', {}, "Bot info:")); /* bot info */
1392						const botInfoDd = dl.appendChild(make('dd'));
1393						botInfoDd.appendChild(make('a', {
1394							'href': data._bot.url,
1395							'target': '_blank'
1396						}, data._bot.url)); /* bot info link*/
1397
1398					}
1399
1400				} else { /* not for bots */
1401
1402					dl.appendChild(make('dt', {}, "Client:")); /* client */
1403					dl.appendChild(make('dd', {'class': 'has_icon client_' + (data._client ? data._client.id : 'unknown')},
1404						clientName + ( data._client.v > 0 ? ' (' + data._client.v + ')' : '' ) ));
1405
1406					dl.appendChild(make('dt', {}, "Platform:")); /* platform */
1407					dl.appendChild(make('dd', {'class': 'has_icon platform_' + (data._platform ? data._platform.id : 'unknown')},
1408						platformName + ( data._platform.v > 0 ? ' (' + data._platform.v + ')' : '' ) ));
1409
1410					dl.appendChild(make('dt', {}, "IP-Address:"));
1411					dl.appendChild(make('dd', {'class': 'has_icon ip' + ipType}, data.ip));
1412
1413					/*dl.appendChild(make('dt', {}, "ID:"));
1414					dl.appendChild(make('dd', {'class': 'has_icon ip' + data.typ}, data.id));*/
1415				}
1416
1417				if (Math.abs(data._lastSeen - data._firstSeen) < 100) {
1418					dl.appendChild(make('dt', {}, "Seen:"));
1419					dl.appendChild(make('dd', {'class': 'seen'}, data._firstSeen.toLocaleString()));
1420				} else {
1421					dl.appendChild(make('dt', {}, "First seen:"));
1422					dl.appendChild(make('dd', {'class': 'firstSeen'}, data._firstSeen.toLocaleString()));
1423					dl.appendChild(make('dt', {}, "Last seen:"));
1424					dl.appendChild(make('dd', {'class': 'lastSeen'}, data._lastSeen.toLocaleString()));
1425				}
1426
1427				dl.appendChild(make('dt', {}, "User-Agent:"));
1428				dl.appendChild(make('dd', {'class': 'agent'}, data.agent));
1429
1430				dl.appendChild(make('dt', {}, "Languages:"));
1431				dl.appendChild(make('dd', {'class': 'langs'}, "Client accepts: [" + data.accept + "]; Page: [" + data.lang + ']'));
1432
1433				/*dl.appendChild(make('dt', {}, "Visitor Type:"));
1434				dl.appendChild(make('dd', undefined, data._type ));*/
1435
1436				dl.appendChild(make('dt', {}, "Seen by:"));
1437				dl.appendChild(make('dd', undefined, data._seenBy.join(', ') ));
1438
1439				dl.appendChild(make('dt', {}, "Visited pages:"));
1440				const pagesDd = make('dd', {'class': 'pages'});
1441				const pageList = make('ul');
1442
1443				/* list all page views */
1444				data._pageViews.forEach( (page) => {
1445					const pgLi = make('li');
1446
1447					let visitTimeStr = "Bounce";
1448					const visitDuration = page._lastSeen.getTime() - page._firstSeen.getTime();
1449					if (visitDuration > 0) {
1450						visitTimeStr = Math.floor(visitDuration / 1000) + "s";
1451					}
1452
1453					pgLi.appendChild(make('span', {}, page.pg)); /* DW Page ID */
1454					if (page._ref) {
1455						pgLi.appendChild(make('span', {
1456							'data-ref': page._ref.host,
1457							'title': "Referrer: " + page._ref.full
1458						}, page._ref.site));
1459					} else {
1460						pgLi.appendChild(make('span', {
1461						}, "No referer"));
1462					}
1463					pgLi.appendChild(make('span', {}, ( page._seenBy ? page._seenBy.join(', ') : '—') + '; ' + page._tickCount));
1464					pgLi.appendChild(make('span', {}, BotMon.t._formatTime(page._firstSeen)));
1465
1466					// get the time difference:
1467					const tDiff = BotMon.t._formatTimeDiff(page._firstSeen, page._lastSeen);
1468					if (tDiff) {
1469						pgLi.appendChild(make('span', {'class': 'visit-length', 'title': 'Last seen: ' + page._lastSeen.toLocaleString()}, tDiff));
1470					} else {
1471						pgLi.appendChild(make('span', {'class': 'bounce'}, "Bounce"));
1472					}
1473
1474					pageList.appendChild(pgLi);
1475				});
1476				pagesDd.appendChild(pageList);
1477				dl.appendChild(pagesDd);
1478
1479				/* bot evaluation rating */
1480				if (data._type !== BM_USERTYPE.KNOWN_BOT && data._type !== BM_USERTYPE.KNOWN_USER) {
1481					dl.appendChild(make('dt', undefined, "Bot rating:"));
1482					dl.appendChild(make('dd', {'class': 'bot-rating'}, ( data._botVal ? data._botVal : '–' ) + '/' + BotMon.live.data.rules._threshold ));
1483
1484					/* add bot evaluation details: */
1485					if (data._eval) {
1486						dl.appendChild(make('dt', {}, "Bot evaluation details:"));
1487						const evalDd = make('dd');
1488						const testList = make('ul',{
1489							'class': 'eval'
1490						});
1491						data._eval.forEach( test => {
1492
1493							const tObj = BotMon.live.data.rules.getRuleInfo(test);
1494							let tDesc = tObj ? tObj.desc : test;
1495
1496							// special case for Bot IP range test:
1497							if (tObj.func == 'fromKnownBotIP') {
1498								const rangeInfo = BotMon.live.data.rules.getBotIPInfo(data.ip);
1499								if (rangeInfo) {
1500									tDesc += ' (' + (rangeInfo.label ? rangeInfo.label : 'Unknown') + ')';
1501								}
1502							}
1503
1504							// create the entry field
1505							const tstLi = make('li');
1506							tstLi.appendChild(make('span', {
1507								'data-testid': test
1508							}, tDesc));
1509							tstLi.appendChild(make('span', {}, ( tObj ? tObj.bot : '—') ));
1510							testList.appendChild(tstLi);
1511						});
1512
1513						// add total row
1514						const tst2Li = make('li', {
1515							'class': 'total'
1516						});
1517						/*tst2Li.appendChild(make('span', {}, "Total:"));
1518						tst2Li.appendChild(make('span', {}, data._botVal));
1519						testList.appendChild(tst2Li);*/
1520
1521						evalDd.appendChild(testList);
1522						dl.appendChild(evalDd);
1523					}
1524				}
1525				// return the element to add to the UI:
1526				return dl;
1527			}
1528
1529		}
1530	}
1531};
1532
1533/* launch only if the BotMon admin panel is open: */
1534if (document.getElementById('botmon__admin')) {
1535	BotMon.init();
1536}