xref: /plugin/botmon/script.js (revision b1aa993aebb79b854cfaf9849a9f81758a7f9595)
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				if (!nv.geo ||nv.geo === '') nv.geo = 'ZZ';
315
316				// country name:
317				try {
318					nv._country = "Undefined";
319					if (nv.geo && nv.geo !== '') {
320						const countryName = new Intl.DisplayNames(['en', BotMon._lang], {type: 'region'});
321						nv._country = countryName.of(nv.geo) ?? "Unknown";
322					}
323				} catch (err) {
324					console.error(err);
325					nv._country = 'Error';
326				}
327
328				// check if it already exists:
329				let visitor = model.findVisitor(nv);
330				if (!visitor) {
331					visitor = nv;
332					visitor._seenBy = [type];
333					visitor._pageViews = []; // array of page views
334					visitor._hasReferrer = false; // has at least one referrer
335					visitor._jsClient = false; // visitor has been seen logged by client js as well
336					visitor._client = BotMon.live.data.clients.match(nv.agent) ?? null; // client info
337					visitor._platform = BotMon.live.data.platforms.match(nv.agent); // platform info
338					model._visitors.push(visitor);
339				} else { // update existing
340					if (visitor._firstSeen > nv.ts) {
341						visitor._firstSeen = nv.ts;
342					}
343				}
344
345				// find browser
346
347				// is this visit already registered?
348				let prereg = model._getPageView(visitor, nv);
349				if (!prereg) {
350					// add new page view:
351					prereg = model._makePageView(nv, type);
352					visitor._pageViews.push(prereg);
353				} else {
354					// update last seen date
355					prereg._lastSeen = nv.ts;
356					// increase view count:
357					prereg._viewCount += 1;
358					prereg._tickCount += 1;
359				}
360
361				// update referrer state:
362				visitor._hasReferrer = visitor._hasReferrer ||
363					(prereg.ref !== undefined && prereg.ref !== '');
364
365				// update time stamp for last-seen:
366				if (visitor._lastSeen < nv.ts) {
367					visitor._lastSeen = nv.ts;
368				}
369
370				// if needed:
371				return visitor;
372			},
373
374			// updating visit data from the client-side log:
375			updateVisit: function(dat) {
376				//console.info('updateVisit', dat);
377
378				// shortcut to make code more readable:
379				const model = BotMon.live.data.model;
380
381				const type = 'log';
382
383				let visitor = BotMon.live.data.model.findVisitor(dat);
384				if (!visitor) {
385					visitor = model.registerVisit(dat, type);
386				}
387				if (visitor) {
388
389					if (visitor._lastSeen < dat.ts) {
390						visitor._lastSeen = dat.ts;
391					}
392					if (!visitor._seenBy.includes(type)) {
393						visitor._seenBy.push(type);
394					}
395					visitor._jsClient = true; // seen by client js
396				}
397
398				// find the page view:
399				let prereg = BotMon.live.data.model._getPageView(visitor, dat);
400				if (prereg) {
401					// update the page view:
402					prereg._lastSeen = dat.ts;
403					if (!prereg._seenBy.includes(type)) prereg._seenBy.push(type);
404					prereg._jsClient = true; // seen by client js
405				} else {
406					// add the page view to the visitor:
407					prereg = model._makePageView(dat, type);
408					visitor._pageViews.push(prereg);
409				}
410				prereg._tickCount += 1;
411			},
412
413			// updating visit data from the ticker log:
414			updateTicks: function(dat) {
415				//console.info('updateTicks', dat);
416
417				// shortcut to make code more readable:
418				const model = BotMon.live.data.model;
419
420				const type = 'tck';
421
422				// find the visit info:
423				let visitor = model.findVisitor(dat);
424				if (!visitor) {
425					console.info(`No visitor with ID “${dat.id}” found, registering as a new one.`);
426					visitor = model.registerVisit(dat, type);
427				}
428				if (visitor) {
429					// update visitor:
430					if (visitor._lastSeen < dat.ts) visitor._lastSeen = dat.ts;
431					if (!visitor._seenBy.includes(type)) visitor._seenBy.push(type);
432
433					// get the page view info:
434					let pv = model._getPageView(visitor, dat);
435					if (!pv) {
436						console.warn(`No page view for visit ID “${dat.id}”, page “${dat.pg}”, registering a new one.`);
437						pv = model._makePageView(dat, type);
438						visitor._pageViews.push(pv);
439					}
440
441					// update the page view info:
442					if (!pv._seenBy.includes(type)) pv._seenBy.push(type);
443					if (pv._lastSeen.getTime() < dat.ts.getTime()) pv._lastSeen = dat.ts;
444					pv._tickCount += 1;
445
446				}
447			},
448
449			// helper function to create a new "page view" item:
450			_makePageView: function(data, type) {
451
452				// try to parse the referrer:
453				let rUrl = null;
454				try {
455					rUrl = ( data.ref && data.ref !== '' ? new URL(data.ref) : null );
456				} catch (e) {
457					console.info(`Invalid referer: “${data.ref}”.`);
458				}
459
460				return {
461					_by: type,
462					ip: data.ip,
463					pg: data.pg,
464					_ref: rUrl,
465					_firstSeen: data.ts,
466					_lastSeen: data.ts,
467					_seenBy: [type],
468					_jsClient: ( type !== 'srv'),
469					_viewCount: 1,
470					_tickCount: 0
471				};
472			}
473		},
474
475		analytics: {
476
477			init: function() {
478				//console.info('BotMon.live.data.analytics.init()');
479			},
480
481			// data storage:
482			data: {
483				totalVisits: 0,
484				totalPageViews: 0,
485				humanPageViews: 0,
486				bots: {
487					known: 0,
488					suspected: 0,
489					human: 0,
490					users: 0
491				}
492			},
493
494			// sort the visits by type:
495			groups: {
496				knownBots: [],
497				suspectedBots: [],
498				humans: [],
499				users: []
500			},
501
502			// all analytics
503			analyseAll: function() {
504				//console.info('BotMon.live.data.analytics.analyseAll()');
505
506				// shortcut to make code more readable:
507				const model = BotMon.live.data.model;
508				const me = BotMon.live.data.analytics;
509
510				BotMon.live.gui.status.showBusy("Analysing data …");
511
512				// loop over all visitors:
513				model._visitors.forEach( (v) => {
514
515					// count visits and page views:
516					this.data.totalVisits += 1;
517					this.data.totalPageViews += v._pageViews.length;
518
519					// check for typical bot aspects:
520					let botScore = 0;
521
522					if (v._type == BM_USERTYPE.KNOWN_BOT) { // known bots
523
524						this.data.bots.known += v._pageViews.length;
525						this.groups.knownBots.push(v);
526
527					} else if (v._type == BM_USERTYPE.KNOWN_USER) { // known users */
528
529						this.data.bots.users += v._pageViews.length;
530						this.groups.users.push(v);
531
532					} else {
533
534						// get evaluation:
535						const e = BotMon.live.data.rules.evaluate(v);
536						v._eval = e.rules;
537						v._botVal = e.val;
538
539						if (e.isBot) { // likely bots
540							v._type = BM_USERTYPE.LIKELY_BOT;
541							this.data.bots.suspected += v._pageViews.length;
542							this.groups.suspectedBots.push(v);
543						} else { // probably humans
544							v._type = BM_USERTYPE.HUMAN;
545							this.data.bots.human += v._pageViews.length;
546							this.groups.humans.push(v);
547						}
548					}
549
550					// perform actions depending on the visitor type:
551					if (v._type == BM_USERTYPE.KNOWN_BOT || v._type == BM_USERTYPE.LIKELY_BOT) { /* bots only */
552
553						// add bot views to IP range information:
554						v._pageViews.forEach( pv => {
555							me.addToIPRanges(pv.ip);
556						});
557
558						// add to the country lists:
559						me.addToCountries(v.geo, v._country, v._type);
560
561					} else { /* humans only */
562
563						// add browser and platform statistics:
564						me.addBrowserPlatform(v);
565					}
566
567				});
568
569				BotMon.live.gui.status.hideBusy('Done.');
570			},
571
572			// visits from IP ranges:
573			_ipRange: {
574				ip4: [],
575				ip6: []
576			},
577			/**
578			 * Adds a visit to the IP range statistics.
579			 *
580			 * This helps to identify IP ranges that are used by bots.
581			 *
582			 * @param {string} ip The IP address to add.
583			 */
584			addToIPRanges: function(ip) {
585
586				// #TODO: handle nestled ranges!
587				const me = BotMon.live.data.analytics;
588				const ipv = (ip.indexOf(':') > 0 ? 6 : 4);
589
590				const ipArr = ip.split( ipv == 6 ? ':' : '.');
591				const maxSegments = (ipv == 6 ? 4 : 3);
592
593				let arr = (ipv == 6 ? me._ipRange.ip6 : me._ipRange.ip4);
594
595				// find any existing segment entry:
596				it = null;
597				for (let i=0; i < arr.length; i++) {
598					const sig = arr[i];
599					if (sig.seg == ipArr[0]) {
600						it = sig;
601						break;
602					}
603				}
604
605				// create if not found:
606				if (!it) {
607					it = {seg: ipArr[0], count: 1};
608					//if (i<maxSegments) it.sub = [];
609					arr.push(it);
610
611				} else { // increase count:
612
613					it.count += 1;
614				}
615
616			},
617			getTopBotIPRanges: function(max) {
618
619				const me = BotMon.live.data.analytics;
620
621				const kMinHits = 2;
622
623				// combine the ip lists, removing all lower volume branches:
624				let ipTypes = [4,6];
625				const tmpList = [];
626				for (let i=0; i<ipTypes.length; i++) {
627					const ipType = ipTypes[i];
628					(ipType == 6 ? me._ipRange.ip6 : me._ipRange.ip4).forEach( it => {
629						if (it.count > kMinHits) {
630							it.type = ipType;
631							tmpList.push(it);
632						}
633						});
634					tmpList.sort( (a,b) => b.count - a.count);
635				}
636
637				// reduce to only the top (max) items and create the target format:
638				// #TODO: handle nestled ranges!
639				let rList = [];
640				for (let j=0; Math.min(max, tmpList.length) > j; j++) {
641					const rangeInfo = tmpList[j];
642					rList.push({
643						'ip': rangeInfo.seg + ( rangeInfo.type == 4 ? '.x.x.x' : '::x'),
644						'typ': rangeInfo.type,
645						'num': rangeInfo.count
646					});
647				}
648
649				return rList;
650			},
651
652			/* countries of visits */
653			_countries: {
654				'user': [],
655				'human': [],
656				'likelyBot': [],
657				'known_bot': []
658
659			},
660			/**
661			 * Adds a country code to the statistics.
662			 *
663			 * @param {string} iso The ISO 3166-1 alpha-2 country code.
664			 */
665			addToCountries: function(iso, name, type) {
666
667				const me = BotMon.live.data.analytics;
668
669				// find the correct array:
670				let arr = null;
671				switch (type) {
672
673					case BM_USERTYPE.KNOWN_USER:
674						arr = me._countries.user;
675						break;
676					case BM_USERTYPE.HUMAN:
677						arr = me._countries.human;
678						break;
679					case BM_USERTYPE.LIKELY_BOT:
680						arr = me._countries.likelyBot;
681						break;
682					case BM_USERTYPE.KNOWN_BOT:
683						arr = me._countries.known_bot;
684						break;
685					default:
686						console.warn(`Unknown user type ${type} in function addToCountries.`);
687				}
688
689				if (arr) {
690					let cRec = arr.find( it => it.iso == iso);
691					if (!cRec) {
692						cRec = {
693							'iso': iso,
694							'name': name,
695							'count': 1
696						};
697						arr.push(cRec);
698					} else {
699						cRec.count += 1;
700					}
701				}
702			},
703
704			/**
705			 * Returns a list of countries with visit counts, sorted by visit count in descending order.
706			 *
707			 * @param {BM_USERTYPE} type The type of visitors to return.
708			 * @param {number} max The maximum number of entries to return.
709			 * @return {Array} A list of objects with properties 'iso' (ISO 3166-1 alpha-2 country code) and 'count' (visit count).
710			 */
711			getCountryList: function(type, max) {
712
713				const me = BotMon.live.data.analytics;
714
715				// find the correct array:
716				let arr = null;
717				switch (type) {
718
719					case BM_USERTYPE.KNOWN_USER:
720						arr = me._countries.user;
721						break;
722					case BM_USERTYPE.HUMAN:
723						arr = me._countries.human;
724						break;
725					case BM_USERTYPE.LIKELY_BOT:
726						arr = me._countries.likelyBot;
727						break;
728					case BM_USERTYPE.KNOWN_BOT:
729						arr = me._countries.known_bot;
730						break;
731					default:
732						console.warn(`Unknown user type ${type} in function getCountryList.`);
733				}
734
735				if (arr) {
736					// sort by visit count:
737					arr.sort( (a,b) => b.count - a.count);
738
739					// reduce to only the top (max) items and create the target format:
740					let rList = [];
741					for (let i=0; Math.min(max, arr.length) > i; i++) {
742						const cRec = arr[i];
743						rList.push({
744							'iso': cRec.iso,
745							'name': cRec.name,
746							'count': cRec.count
747						});
748					}
749					return rList;
750				}
751				return [];
752			},
753
754			/* browser and platform of human visitors */
755			_browsers: [],
756			_platforms: [],
757
758			addBrowserPlatform: function(visitor) {
759				//console.info('addBrowserPlatform', visitor);
760
761				const me = BotMon.live.data.analytics;
762
763				// add to browsers list:
764				let browserRec = ( visitor._client ? visitor._client : {'id': 'unknown'});
765				if (visitor._client) {
766					let bRec = me._browsers.find( it => it.id == browserRec.id);
767					if (!bRec) {
768						bRec = {
769							'id': browserRec.id,
770							'count': 1
771						};
772						me._browsers.push(bRec);
773					} else {
774						bRec.count += 1;
775					}
776				}
777
778				// add to platforms list:
779				let platformRec = ( visitor._platform ? visitor._platform : {'id': 'unknown'});
780				if (visitor._platform) {
781					let pRec = me._platforms.find( it => it.id == platformRec.id);
782					if (!pRec) {
783						pRec = {
784							'id': platformRec.id,
785							'count': 1
786						};
787						me._platforms.push(pRec);
788					} else {
789						pRec.count += 1;
790					}
791				}
792
793			},
794
795			getTopBrowsers: function(max) {
796
797				const me = BotMon.live.data.analytics;
798
799				me._browsers.sort( (a,b) => b.count - a.count);
800
801				// how many browsers to show:
802				const max2 = ( me._browsers.length >= max ? max-1 : max );
803
804				const rArr = []; // return array
805				let total = 0;
806				const others = {
807					'id': 'other',
808					'name': "Others",
809					'count': 0
810				}
811				for (let i=0; i < me._browsers.length; i++) {
812					if (i < max2) {
813						rArr.push({
814							'id': me._browsers[i].id,
815							'name': BotMon.live.data.clients.getName(me._browsers[i].id),
816							'count': me._browsers[i].count
817						});
818						total += me._browsers[i].count;
819					} else {
820						others.count += me._browsers[i].count;
821						total += me._browsers[i].count;
822					}
823				};
824
825				if (me._browsers.length > (max-1)) {
826					rArr.push(others);
827				}
828
829				// update percentages:
830				rArr.forEach( it => {
831					it.pct = Math.round(it.count * 100 / total);
832				})
833
834				return rArr;
835			},
836
837			getTopPlatforms: function(max) {
838
839				const me = BotMon.live.data.analytics;
840
841				me._platforms.sort( (a,b) => b.count - a.count);
842				// how many browsers to show:
843				const max2 = ( me._platforms.length >= max ? max-1 : max );
844
845				const rArr = []; // return array
846				let total = 0;
847				const others = {
848					'id': 'other',
849					'name': "Others",
850					'count': 0
851				}
852				for (let i=0; i < me._platforms.length; i++) {
853					if (i < max2) {
854						rArr.push({
855							'id': me._platforms[i].id,
856							'name': BotMon.live.data.platforms.getName(me._platforms[i].id),
857							'count': me._platforms[i].count
858						});
859						total += me._platforms[i].count;
860					} else {
861						others.count += me._platforms[i].count;
862						total += me._platforms[i].count;
863					}
864				};
865
866				if (me._platforms.length > (max-1)) {
867					rArr.push(others);
868				}
869
870				// update percentages:
871				rArr.forEach( it => {
872					it.pct = Math.round(it.count * 100 / total);
873				})
874
875				return rArr;
876			}
877		},
878
879		bots: {
880			// loads the list of known bots from a JSON file:
881			init: async function() {
882				//console.info('BotMon.live.data.bots.init()');
883
884				// Load the list of known bots:
885				BotMon.live.gui.status.showBusy("Loading known bots …");
886				const url = BotMon._baseDir + 'config/known-bots.json';
887				try {
888					const response = await fetch(url);
889					if (!response.ok) {
890						throw new Error(`${response.status} ${response.statusText}`);
891					}
892
893					this._list = await response.json();
894					this._ready = true;
895
896				} catch (error) {
897					BotMon.live.gui.status.setError("Error while loading the ‘known bots’ file: " + error.message);
898				} finally {
899					BotMon.live.gui.status.hideBusy("Status: Done.");
900					BotMon.live.data._dispatch('bots')
901				}
902			},
903
904			// returns bot info if the clientId matches a known bot, null otherwise:
905			match: function(agent) {
906				//console.info('BotMon.live.data.bots.match(',agent,')');
907
908				const BotList = BotMon.live.data.bots._list;
909
910				// default is: not found!
911				let botInfo = null;
912
913				if (!agent) return null;
914
915				// check for known bots:
916				BotList.find(bot => {
917					let r = false;
918					for (let j=0; j<bot.rx.length; j++) {
919						const rxr = agent.match(new RegExp(bot.rx[j]));
920						if (rxr) {
921							botInfo = {
922								n : bot.n,
923								id: bot.id,
924								url: bot.url,
925								v: (rxr.length > 1 ? rxr[1] : -1)
926							};
927							r = true;
928							break;
929						};
930					};
931					return r;
932				});
933
934				// check for unknown bots:
935				if (!botInfo) {
936					const botmatch = agent.match(/([\s\d\w]*bot|[\s\d\w]*crawler|[\s\d\w]*spider)[\/\s;\),\\.$]/i);
937					if(botmatch) {
938						botInfo = {'id': ( botmatch[1] || "other_" ), 'n': "Other" + ( botmatch[1] ? " (" + botmatch[1] + ")" : "" ) , "bot": botmatch[1] };
939					}
940				}
941
942				//console.log("botInfo:", botInfo);
943				return botInfo;
944			},
945
946
947			// indicates if the list is loaded and ready to use:
948			_ready: false,
949
950			// the actual bot list is stored here:
951			_list: []
952		},
953
954		clients: {
955			// loads the list of known clients from a JSON file:
956			init: async function() {
957				//console.info('BotMon.live.data.clients.init()');
958
959				// Load the list of known bots:
960				BotMon.live.gui.status.showBusy("Loading known clients");
961				const url = BotMon._baseDir + 'config/known-clients.json';
962				try {
963					const response = await fetch(url);
964					if (!response.ok) {
965						throw new Error(`${response.status} ${response.statusText}`);
966					}
967
968					BotMon.live.data.clients._list = await response.json();
969					BotMon.live.data.clients._ready = true;
970
971				} catch (error) {
972					BotMon.live.gui.status.setError("Error while loading the known clients file: " + error.message);
973				} finally {
974					BotMon.live.gui.status.hideBusy("Status: Done.");
975					BotMon.live.data._dispatch('clients')
976				}
977			},
978
979			// returns bot info if the user-agent matches a known bot, null otherwise:
980			match: function(agent) {
981				//console.info('BotMon.live.data.clients.match(',agent,')');
982
983				let match = {"n": "Unknown", "v": -1, "id": null};
984
985				if (agent) {
986					BotMon.live.data.clients._list.find(client => {
987						let r = false;
988						for (let j=0; j<client.rx.length; j++) {
989							const rxr = agent.match(new RegExp(client.rx[j]));
990							if (rxr) {
991								match.n = client.n;
992								match.v = (rxr.length > 1 ? rxr[1] : -1);
993								match.id = client.id || null;
994								r = true;
995								break;
996							}
997						}
998						return r;
999					});
1000				}
1001
1002				//console.log(match)
1003				return match;
1004			},
1005
1006			// return the browser name for a browser ID:
1007			getName: function(id) {
1008				const it = BotMon.live.data.clients._list.find(client => client.id == id);
1009				return it.n;
1010			},
1011
1012			// indicates if the list is loaded and ready to use:
1013			_ready: false,
1014
1015			// the actual bot list is stored here:
1016			_list: []
1017
1018		},
1019
1020		platforms: {
1021			// loads the list of known platforms from a JSON file:
1022			init: async function() {
1023				//console.info('BotMon.live.data.platforms.init()');
1024
1025				// Load the list of known bots:
1026				BotMon.live.gui.status.showBusy("Loading known platforms");
1027				const url = BotMon._baseDir + 'config/known-platforms.json';
1028				try {
1029					const response = await fetch(url);
1030					if (!response.ok) {
1031						throw new Error(`${response.status} ${response.statusText}`);
1032					}
1033
1034					BotMon.live.data.platforms._list = await response.json();
1035					BotMon.live.data.platforms._ready = true;
1036
1037				} catch (error) {
1038					BotMon.live.gui.status.setError("Error while loading the known platforms file: " + error.message);
1039				} finally {
1040					BotMon.live.gui.status.hideBusy("Status: Done.");
1041					BotMon.live.data._dispatch('platforms')
1042				}
1043			},
1044
1045			// returns bot info if the browser id matches a known platform:
1046			match: function(cid) {
1047				//console.info('BotMon.live.data.platforms.match(',cid,')');
1048
1049				let match = {"n": "Unknown", "id": null};
1050
1051				if (cid) {
1052					BotMon.live.data.platforms._list.find(platform => {
1053						let r = false;
1054						for (let j=0; j<platform.rx.length; j++) {
1055							const rxr = cid.match(new RegExp(platform.rx[j]));
1056							if (rxr) {
1057								match.n = platform.n;
1058								match.v = (rxr.length > 1 ? rxr[1] : -1);
1059								match.id = platform.id || null;
1060								r = true;
1061								break;
1062							}
1063						}
1064						return r;
1065					});
1066				}
1067
1068				return match;
1069			},
1070
1071			// return the platform name for a given ID:
1072			getName: function(id) {
1073				const it = BotMon.live.data.platforms._list.find( pf => pf.id == id);
1074				console.log(it);
1075				return ( it ? it.n : 'Unknown' );
1076			},
1077
1078
1079			// indicates if the list is loaded and ready to use:
1080			_ready: false,
1081
1082			// the actual bot list is stored here:
1083			_list: []
1084
1085		},
1086
1087		rules: {
1088			// loads the list of rules and settings from a JSON file:
1089			init: async function() {
1090				//console.info('BotMon.live.data.rules.init()');
1091
1092				// Load the list of known bots:
1093				BotMon.live.gui.status.showBusy("Loading list of rules …");
1094				const url = BotMon._baseDir + 'config/botmon-config.json';
1095				try {
1096					const response = await fetch(url);
1097					if (!response.ok) {
1098						throw new Error(`${response.status} ${response.statusText}`);
1099					}
1100
1101					const json = await response.json();
1102
1103					if (json.rules) {
1104						this._rulesList = json.rules;
1105					}
1106
1107					if (json.threshold) {
1108						this._threshold = json.threshold;
1109					}
1110
1111					if (json.ipRanges) {
1112						// clean up the IPs first:
1113						let list = [];
1114						json.ipRanges.forEach( it => {
1115							let item = {
1116								'from': BotMon.t._ip2Num(it.from),
1117								'to': BotMon.t._ip2Num(it.to),
1118								'label': it.label
1119							};
1120							list.push(item);
1121						});
1122
1123						this._botIPs = list;
1124					}
1125
1126					this._ready = true;
1127
1128				} catch (error) {
1129					BotMon.live.gui.status.setError("Error while loading the ‘rules’ file: " + error.message);
1130				} finally {
1131					BotMon.live.gui.status.hideBusy("Status: Done.");
1132					BotMon.live.data._dispatch('rules')
1133				}
1134			},
1135
1136			_rulesList: [], // list of rules to find out if a visitor is a bot
1137			_threshold: 100, // above this, it is considered a bot.
1138
1139			// returns a descriptive text for a rule id
1140			getRuleInfo: function(ruleId) {
1141				// console.info('getRuleInfo', ruleId);
1142
1143				// shortcut for neater code:
1144				const me = BotMon.live.data.rules;
1145
1146				for (let i=0; i<me._rulesList.length; i++) {
1147					const rule = me._rulesList[i];
1148					if (rule.id == ruleId) {
1149						return rule;
1150					}
1151				}
1152				return null;
1153
1154			},
1155
1156			// evaluate a visitor for lkikelihood of being a bot
1157			evaluate: function(visitor) {
1158
1159				// shortcut for neater code:
1160				const me = BotMon.live.data.rules;
1161
1162				let r =  {	// evaluation result
1163					'val': 0,
1164					'rules': [],
1165					'isBot': false
1166				};
1167
1168				for (let i=0; i<me._rulesList.length; i++) {
1169					const rule = me._rulesList[i];
1170					const params = ( rule.params ? rule.params : [] );
1171
1172					if (rule.func) { // rule is calling a function
1173						if (me.func[rule.func]) {
1174							if(me.func[rule.func](visitor, ...params)) {
1175								r.val += rule.bot;
1176								r.rules.push(rule.id)
1177							}
1178						} else {
1179							//console.warn("Unknown rule function: “${rule.func}”. Ignoring rule.")
1180						}
1181					}
1182				}
1183
1184				// is a bot?
1185				r.isBot = (r.val >= me._threshold);
1186
1187				return r;
1188			},
1189
1190			// list of functions that can be called by the rules list to evaluate a visitor:
1191			func: {
1192
1193				// check if client is on the list passed as parameter:
1194				matchesClient: function(visitor, ...clients) {
1195
1196					const clientId = ( visitor._client ? visitor._client.id : '');
1197					return clients.includes(clientId);
1198				},
1199
1200				// check if OS/Platform is one of the obsolete ones:
1201				matchesPlatform: function(visitor, ...platforms) {
1202
1203					const pId = ( visitor._platform ? visitor._platform.id : '');
1204					return platforms.includes(pId);
1205				},
1206
1207				// are there at lest num pages loaded?
1208				smallPageCount: function(visitor, num) {
1209					return (visitor._pageViews.length <= Number(num));
1210				},
1211
1212				// There was no entry in a specific log file for this visitor:
1213				// note that this will also trigger the "noJavaScript" rule:
1214				noRecord: function(visitor, type) {
1215					return !visitor._seenBy.includes(type);
1216				},
1217
1218				// there are no referrers in any of the page visits:
1219				noReferrer: function(visitor) {
1220
1221					let r = false; // return value
1222					for (let i = 0; i < visitor._pageViews.length; i++) {
1223						if (!visitor._pageViews[i]._ref) {
1224							r = true;
1225							break;
1226						}
1227					}
1228					return r;
1229				},
1230
1231				// test for specific client identifiers:
1232				/*matchesClients: function(visitor, ...list) {
1233
1234					for (let i=0; i<list.length; i++) {
1235						if (visitor._client.id == list[i]) {
1236								return true
1237						}
1238					};
1239					return false;
1240				},*/
1241
1242				// unusual combinations of Platform and Client:
1243				combinationTest: function(visitor, ...combinations) {
1244
1245					for (let i=0; i<combinations.length; i++) {
1246
1247						if (visitor._platform.id == combinations[i][0]
1248							&& visitor._client.id == combinations[i][1]) {
1249								return true
1250						}
1251					};
1252
1253					return false;
1254				},
1255
1256				// is the IP address from a known bot network?
1257				fromKnownBotIP: function(visitor) {
1258
1259					const ipInfo = BotMon.live.data.rules.getBotIPInfo(visitor.ip);
1260
1261					if (ipInfo) {
1262						visitor._ipInKnownBotRange = true;
1263					}
1264
1265					return (ipInfo !== null);
1266				},
1267
1268				// is the page language mentioned in the client's accepted languages?
1269				// the parameter holds an array of exceptions, i.e. page languages that should be ignored.
1270				matchLang: function(visitor, ...exceptions) {
1271
1272					if (visitor.lang && visitor.accept && exceptions.indexOf(visitor.lang) < 0) {
1273						return (visitor.accept.split(',').indexOf(visitor.lang) < 0);
1274					}
1275					return false;
1276				},
1277
1278				// Is there an accept-language field defined at all?
1279				noAcceptLang: function(visitor) {
1280
1281					if (!visitor.accept || visitor.accept.length <= 0) { // no accept-languages header
1282						return true;
1283					}
1284					// TODO: parametrize this!
1285					return false;
1286				},
1287				// At least x page views were recorded, but they come within less than y seconds
1288				loadSpeed: function(visitor, minItems, maxTime) {
1289
1290					if (visitor._pageViews.length >= minItems) {
1291						//console.log('loadSpeed', visitor._pageViews.length, minItems, maxTime);
1292
1293						const pvArr = visitor._pageViews.map(pv => pv._lastSeen).sort();
1294
1295						let totalTime = 0;
1296						for (let i=1; i < pvArr.length; i++) {
1297							totalTime += (pvArr[i] - pvArr[i-1]);
1298						}
1299
1300						//console.log('     ', totalTime , Math.round(totalTime / (pvArr.length * 1000)), (( totalTime / pvArr.length ) <= maxTime * 1000), visitor.ip);
1301
1302						return (( totalTime / pvArr.length ) <= maxTime * 1000);
1303					}
1304				},
1305
1306				// Country code matches one of those in the list:
1307				matchesCountry: function(visitor, ...countries) {
1308
1309					// ingore if geoloc is not set or unknown:
1310					if (visitor.geo && visitor.geo !== 'ZZ') {
1311						return (countries.indexOf(visitor.geo) >= 0);
1312					}
1313					return false;
1314				},
1315
1316				// Country does not match one of the given codes.
1317				notFromCountry: function(visitor, ...countries) {
1318
1319					// ingore if geoloc is not set or unknown:
1320					if (visitor.geo && visitor.geo !== 'ZZ') {
1321						return (countries.indexOf(visitor.geo) < 0);
1322					}
1323					return false;
1324				}
1325			},
1326
1327			/* known bot IP ranges: */
1328			_botIPs: [],
1329
1330			// return information on a bot IP range:
1331			getBotIPInfo: function(ip) {
1332
1333				// shortcut to make code more readable:
1334				const me = BotMon.live.data.rules;
1335
1336				// convert IP address to easier comparable form:
1337				const ipNum = BotMon.t._ip2Num(ip);
1338
1339				for (let i=0; i < me._botIPs.length; i++) {
1340					const ipRange = me._botIPs[i];
1341
1342					if (ipNum >= ipRange.from && ipNum <= ipRange.to) {
1343						return ipRange;
1344					}
1345
1346				};
1347				return null;
1348
1349			}
1350
1351		},
1352
1353	/**
1354	 * Loads a log file (server, page load, or ticker) and parses it.
1355	 * @param {String} type - the type of the log file to load (srv, log, or tck)
1356	 * @param {Function} [onLoaded] - an optional callback function to call after loading is finished.
1357	 */
1358		loadLogFile: async function(type, onLoaded = undefined) {
1359			//console.info('BotMon.live.data.loadLogFile(',type,')');
1360
1361			let typeName = '';
1362			let columns = [];
1363
1364			switch (type) {
1365				case "srv":
1366					typeName = "Server";
1367					columns = ['ts','ip','pg','id','typ','usr','agent','ref','lang','accept','geo'];
1368					break;
1369				case "log":
1370					typeName = "Page load";
1371					columns = ['ts','ip','pg','id','usr','lt','ref','agent'];
1372					break;
1373				case "tck":
1374					typeName = "Ticker";
1375					columns = ['ts','ip','pg','id','agent'];
1376					break;
1377				default:
1378					console.warn(`Unknown log type ${type}.`);
1379					return;
1380			}
1381
1382			// Show the busy indicator and set the visible status:
1383			BotMon.live.gui.status.showBusy(`Loading ${typeName} log file …`);
1384
1385			// compose the URL from which to load:
1386			const url = BotMon._baseDir + `logs/${BotMon._today}.${type}.txt`;
1387			//console.log("Loading:",url);
1388
1389			// fetch the data:
1390			try {
1391				const response = await fetch(url);
1392				if (!response.ok) {
1393					throw new Error(`${response.status} ${response.statusText}`);
1394				}
1395
1396				const logtxt = await response.text();
1397
1398				logtxt.split('\n').forEach((line) => {
1399					if (line.trim() === '') return; // skip empty lines
1400					const cols = line.split('\t');
1401
1402					// assign the columns to an object:
1403					const data = {};
1404					cols.forEach( (colVal,i) => {
1405						colName = columns[i] || `col${i}`;
1406						const colValue = (colName == 'ts' ? new Date(colVal) : colVal.trim());
1407						data[colName] = colValue;
1408					});
1409
1410					// register the visit in the model:
1411					switch(type) {
1412						case 'srv':
1413							BotMon.live.data.model.registerVisit(data, type);
1414							break;
1415						case 'log':
1416							data.typ = 'js';
1417							BotMon.live.data.model.updateVisit(data);
1418							break;
1419						case 'tck':
1420							data.typ = 'js';
1421							BotMon.live.data.model.updateTicks(data);
1422							break;
1423						default:
1424							console.warn(`Unknown log type ${type}.`);
1425							return;
1426					}
1427				});
1428
1429				if (onLoaded) {
1430					onLoaded(); // callback after loading is finished.
1431				}
1432
1433			} catch (error) {
1434				BotMon.live.gui.status.setError(`Error while loading the ${typeName} log file: ${error.message}.`);
1435			} finally {
1436				BotMon.live.gui.status.hideBusy("Status: Done.");
1437			}
1438		}
1439	},
1440
1441	gui: {
1442		init: function() {
1443			// init the lists view:
1444			this.lists.init();
1445		},
1446
1447		overview: {
1448			make: function() {
1449
1450				const data = BotMon.live.data.analytics.data;
1451
1452				// shortcut for neater code:
1453				const makeElement = BotMon.t._makeElement;
1454
1455				const botsVsHumans = document.getElementById('botmon__today__botsvshumans');
1456				if (botsVsHumans) {
1457					botsVsHumans.appendChild(makeElement('dt', {}, "Bots vs. Humans"));
1458
1459					for (let i = 3; i >= 0; i--) {
1460						const dd = makeElement('dd');
1461						let title = '';
1462						let value = '';
1463						switch(i) {
1464							case 0:
1465								title = "Registered users:";
1466								value = data.bots.users;
1467								break;
1468							case 1:
1469								title = "Probably humans:";
1470								value = data.bots.human;
1471								break;
1472							case 2:
1473								title = "Suspected bots:";
1474								value = data.bots.suspected;
1475								break;
1476							case 3:
1477								title = "Known bots:";
1478								value = data.bots.known;
1479								break;
1480							default:
1481								console.warn(`Unknown list type ${i}.`);
1482						}
1483						dd.appendChild(makeElement('span', {}, title));
1484						dd.appendChild(makeElement('strong', {}, value));
1485						botsVsHumans.appendChild(dd);
1486					}
1487				}
1488
1489				// update known bots list:
1490				const botlist = document.getElementById('botmon__botslist');
1491				botlist.innerHTML = "<dt>Known bots (top 4)</dt>";
1492
1493				let bots = BotMon.live.data.analytics.groups.knownBots.toSorted( (a, b) => {
1494					return b._pageViews.length - a._pageViews.length;
1495				});
1496
1497				for (let i=0; i < Math.min(bots.length, 4); i++) {
1498					const dd = makeElement('dd');
1499					dd.appendChild(makeElement('span', {'class': 'bot bot_' + bots[i]._bot.id }, bots[i]._bot.n));
1500					dd.appendChild(makeElement('strong', undefined, bots[i]._pageViews.length));
1501					botlist.appendChild(dd);
1502				}
1503
1504				// update the suspected bot IP ranges list:
1505				const botIps = document.getElementById('botmon__today__botips');
1506				if (botIps) {
1507					botIps.appendChild(makeElement('dt', {}, "Bot IP ranges (top 4)"));
1508
1509					const ipList = BotMon.live.data.analytics.getTopBotIPRanges(4);
1510					ipList.forEach( (ipInfo) => {
1511						const li = makeElement('dd');
1512						li.appendChild(makeElement('span', {'class': 'ip ip' + ipInfo.typ }, ipInfo.ip));
1513						li.appendChild(makeElement('span', {'class': 'count' }, ipInfo.num));
1514						botIps.append(li)
1515					});
1516				}
1517
1518				// update the top bot countries list:
1519				const botCountries = document.getElementById('botmon__today__countries');
1520				if (botCountries) {
1521					botCountries.appendChild(makeElement('dt', {}, "Bot Countries (top 4)"));
1522					const countryList = BotMon.live.data.analytics.getCountryList('likely_bot', 4);
1523					countryList.forEach( (cInfo) => {
1524						const cLi = makeElement('dd');
1525						cLi.appendChild(makeElement('span', {'class': 'country ctry_' + cInfo.iso }, cInfo.name));
1526						cLi.appendChild(makeElement('span', {'class': 'count' }, cInfo.count));
1527						botCountries.appendChild(cLi);
1528					});
1529				}
1530
1531				// update the webmetrics overview:
1532				const wmoverview = document.getElementById('botmon__today__wm_overview');
1533				if (wmoverview) {
1534					const bounceRate = Math.round(data.totalVisits / data.totalPageViews * 100);
1535
1536					wmoverview.appendChild(makeElement('dt', {}, "Overview"));
1537					for (let i = 0; i < 3; i++) {
1538						const dd = makeElement('dd');
1539						let title = '';
1540						let value = '';
1541						switch(i) {
1542							case 0:
1543								title = "Total page views:";
1544								value = data.totalPageViews;
1545								break;
1546							case 1:
1547								title = "Total visitors (est.):";
1548								value = data.totalVisits;
1549								break;
1550							case 2:
1551								title = "Bounce rate (est.):";
1552								value = bounceRate + '%';
1553								break;
1554							default:
1555								console.warn(`Unknown list type ${i}.`);
1556						}
1557						dd.appendChild(makeElement('span', {}, title));
1558						dd.appendChild(makeElement('strong', {}, value));
1559						wmoverview.appendChild(dd);
1560					}
1561				}
1562
1563				// update the webmetrics clients list:
1564				const wmclients = document.getElementById('botmon__today__wm_clients');
1565				if (wmclients) {
1566
1567					wmclients.appendChild(makeElement('dt', {}, "Browsers (humans only)"));
1568
1569					const clientList = BotMon.live.data.analytics.getTopBrowsers(5);
1570					if (clientList) {
1571						clientList.forEach( (cInfo) => {
1572							const cDd = makeElement('dd');
1573							cDd.appendChild(makeElement('span', {'class': 'has_icon client_' + cInfo.id }, ( cInfo.name ? cInfo.name : cInfo.id)));
1574							cDd.appendChild(makeElement('span', {
1575								'class': 'count',
1576								'title': cInfo.count + " page views"
1577							}, Math.round(cInfo.pct) + '%'));
1578							wmclients.appendChild(cDd);
1579						});
1580					}
1581				}
1582
1583				// update the webmetrics platforms list:
1584				const wmplatforms = document.getElementById('botmon__today__wm_platforms');
1585				if (wmplatforms) {
1586
1587					wmplatforms.appendChild(makeElement('dt', {}, "Platforms (humans only)"));
1588
1589					const pfList = BotMon.live.data.analytics.getTopPlatforms(5);
1590					if (pfList) {
1591						pfList.forEach( (pInfo) => {
1592							const pDd = makeElement('dd');
1593							pDd.appendChild(makeElement('span', {'class': 'has_icon client_' + pInfo.id }, ( pInfo.name ? pInfo.name : pInfo.id)));
1594							pDd.appendChild(makeElement('span', {
1595								'class': 'count',
1596								'title': pInfo.count + " page views"
1597							}, Math.round(pInfo.pct) + '%'));
1598							wmplatforms.appendChild(pDd);
1599						});
1600					}
1601				}
1602
1603			}
1604		},
1605
1606		status: {
1607			setText: function(txt) {
1608				const el = document.getElementById('botmon__today__status');
1609				if (el && BotMon.live.gui.status._errorCount <= 0) {
1610					el.innerText = txt;
1611				}
1612			},
1613
1614			setTitle: function(html) {
1615				const el = document.getElementById('botmon__today__title');
1616				if (el) {
1617					el.innerHTML = html;
1618				}
1619			},
1620
1621			setError: function(txt) {
1622				console.error(txt);
1623				BotMon.live.gui.status._errorCount += 1;
1624				const el = document.getElementById('botmon__today__status');
1625				if (el) {
1626					el.innerText = "An error occured. See the browser log for details!";
1627					el.classList.add('error');
1628				}
1629			},
1630			_errorCount: 0,
1631
1632			showBusy: function(txt = null) {
1633				BotMon.live.gui.status._busyCount += 1;
1634				const el = document.getElementById('botmon__today__busy');
1635				if (el) {
1636					el.style.display = 'inline-block';
1637				}
1638				if (txt) BotMon.live.gui.status.setText(txt);
1639			},
1640			_busyCount: 0,
1641
1642			hideBusy: function(txt = null) {
1643				const el = document.getElementById('botmon__today__busy');
1644				BotMon.live.gui.status._busyCount -= 1;
1645				if (BotMon.live.gui.status._busyCount <= 0) {
1646					if (el) el.style.display = 'none';
1647					if (txt) BotMon.live.gui.status.setText(txt);
1648				}
1649			}
1650		},
1651
1652		lists: {
1653			init: function() {
1654
1655				// function shortcut:
1656				const makeElement = BotMon.t._makeElement;
1657
1658				const parent = document.getElementById('botmon__today__visitorlists');
1659				if (parent) {
1660
1661					for (let i=0; i < 4; i++) {
1662
1663						// change the id and title by number:
1664						let listTitle = '';
1665						let listId = '';
1666						switch (i) {
1667							case 0:
1668								listTitle = "Registered users";
1669								listId = 'users';
1670								break;
1671							case 1:
1672								listTitle = "Probably humans";
1673								listId = 'humans';
1674								break;
1675							case 2:
1676								listTitle = "Suspected bots";
1677								listId = 'suspectedBots';
1678								break;
1679							case 3:
1680								listTitle = "Known bots";
1681								listId = 'knownBots';
1682								break;
1683							default:
1684								console.warn('Unknown list number.');
1685						}
1686
1687						const details = makeElement('details', {
1688							'data-group': listId,
1689							'data-loaded': false
1690						});
1691						const title = details.appendChild(makeElement('summary'));
1692						title.appendChild(makeElement('span', {'class':'title'}, listTitle));
1693						title.appendChild(makeElement('span', {'class':'counter'}, '–'));
1694						details.addEventListener("toggle", this._onDetailsToggle);
1695
1696						parent.appendChild(details);
1697
1698					}
1699				}
1700			},
1701
1702			_onDetailsToggle: function(e) {
1703				//console.info('BotMon.live.gui.lists._onDetailsToggle()');
1704
1705				const target = e.target;
1706
1707				if (target.getAttribute('data-loaded') == 'false') { // only if not loaded yet
1708					target.setAttribute('data-loaded', 'loading');
1709
1710					const fillType = target.getAttribute('data-group');
1711					const fillList = BotMon.live.data.analytics.groups[fillType];
1712					if (fillList && fillList.length > 0) {
1713
1714						const ul = BotMon.t._makeElement('ul');
1715
1716						fillList.forEach( (it) => {
1717							ul.appendChild(BotMon.live.gui.lists._makeVisitorItem(it, fillType));
1718						});
1719
1720						target.appendChild(ul);
1721						target.setAttribute('data-loaded', 'true');
1722					} else {
1723						target.setAttribute('data-loaded', 'false');
1724					}
1725
1726				}
1727			},
1728
1729			_makeVisitorItem: function(data, type) {
1730
1731				// shortcut for neater code:
1732				const make = BotMon.t._makeElement;
1733
1734				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
1735				const platformName = (data._platform ? data._platform.n : 'Unknown');
1736				const clientName = (data._client ? data._client.n: 'Unknown');
1737
1738				const li = make('li'); // root list item
1739				const details = make('details');
1740				const summary = make('summary');
1741				details.appendChild(summary);
1742
1743				const span1 = make('span'); /* left-hand group */
1744
1745				// country flag:
1746				if (data.geo && data.geo !=='') {
1747					span1.appendChild(make('span', {
1748						'class': 'icon country ctry_' + data.geo.toLowerCase(),
1749						'data-ctry': data.geo,
1750						'title': "Country: " + data._country
1751					}, data._country));
1752				}
1753
1754				// identifier:
1755				if (data._type == BM_USERTYPE.KNOWN_BOT) { /* Bot only */
1756
1757					const botName = ( data._bot && data._bot.n ? data._bot.n : "Unknown");
1758					span1.appendChild(make('span', { /* Bot */
1759						'class': 'bot bot_' + (data._bot ? data._bot.id : 'unknown'),
1760						'title': "Bot: " + botName
1761					}, botName));
1762
1763				} else if (data._type == BM_USERTYPE.KNOWN_USER) { /* User only */
1764
1765					span1.appendChild(make('span', { /* User */
1766						'class': 'user_known',
1767						'title': "User: " + data.usr
1768					}, data.usr));
1769
1770				} else { /* others */
1771
1772					if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
1773					span1.appendChild(make('span', { /* IP-Address */
1774						'class': 'ipaddr ip' + ipType,
1775						'title': "IP-Address: " + data.ip
1776					}, data.ip));
1777				}
1778
1779				if (data._type !== BM_USERTYPE.KNOWN_BOT) { /* Not for bots */
1780					span1.appendChild(make('span', { /* Platform */
1781						'class': 'icon platform platform_' + (data._platform ? data._platform.id : 'unknown'),
1782						'title': "Platform: " + platformName
1783					}, platformName));
1784
1785					span1.appendChild(make('span', { /* Client */
1786						'class': 'icon client client_' + (data._client ? data._client.id : 'unknown'),
1787						'title': "Client: " + clientName
1788					}, clientName));
1789				}
1790
1791				summary.appendChild(span1);
1792				const span2 = make('span'); /* right-hand group */
1793
1794				span2.appendChild(make('span', { /* page views */
1795					'class': 'pageviews'
1796				}, data._pageViews.length));
1797
1798				summary.appendChild(span2);
1799
1800				// add details expandable section:
1801				details.appendChild(BotMon.live.gui.lists._makeVisitorDetails(data, type));
1802
1803				li.appendChild(details);
1804				return li;
1805			},
1806
1807			_makeVisitorDetails: function(data, type) {
1808
1809				// shortcut for neater code:
1810				const make = BotMon.t._makeElement;
1811
1812				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
1813				if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
1814				const platformName = (data._platform ? data._platform.n : 'Unknown');
1815				const clientName = (data._client ? data._client.n: 'Unknown');
1816
1817				const dl = make('dl', {'class': 'visitor_details'});
1818
1819				if (data._type == BM_USERTYPE.KNOWN_BOT) {
1820
1821					dl.appendChild(make('dt', {}, "Bot name:")); /* bot info */
1822					dl.appendChild(make('dd', {'class': 'has_icon bot bot_' + (data._bot ? data._bot.id : 'unknown')},
1823						(data._bot ? data._bot.n : 'Unknown')));
1824
1825					if (data._bot && data._bot.url) {
1826						dl.appendChild(make('dt', {}, "Bot info:")); /* bot info */
1827						const botInfoDd = dl.appendChild(make('dd'));
1828						botInfoDd.appendChild(make('a', {
1829							'href': data._bot.url,
1830							'target': '_blank'
1831						}, data._bot.url)); /* bot info link*/
1832
1833					}
1834
1835				} else { /* not for bots */
1836
1837					dl.appendChild(make('dt', {}, "Client:")); /* client */
1838					dl.appendChild(make('dd', {'class': 'has_icon client_' + (data._client ? data._client.id : 'unknown')},
1839						clientName + ( data._client.v > 0 ? ' (' + data._client.v + ')' : '' ) ));
1840
1841					dl.appendChild(make('dt', {}, "Platform:")); /* platform */
1842					dl.appendChild(make('dd', {'class': 'has_icon platform_' + (data._platform ? data._platform.id : 'unknown')},
1843						platformName + ( data._platform.v > 0 ? ' (' + data._platform.v + ')' : '' ) ));
1844
1845					dl.appendChild(make('dt', {}, "IP-Address:"));
1846					dl.appendChild(make('dd', {'class': 'has_icon ip' + ipType}, data.ip));
1847
1848					/*dl.appendChild(make('dt', {}, "ID:"));
1849					dl.appendChild(make('dd', {'class': 'has_icon ip' + data.typ}, data.id));*/
1850				}
1851
1852				if (Math.abs(data._lastSeen - data._firstSeen) < 100) {
1853					dl.appendChild(make('dt', {}, "Seen:"));
1854					dl.appendChild(make('dd', {'class': 'seen'}, data._firstSeen.toLocaleString()));
1855				} else {
1856					dl.appendChild(make('dt', {}, "First seen:"));
1857					dl.appendChild(make('dd', {'class': 'firstSeen'}, data._firstSeen.toLocaleString()));
1858					dl.appendChild(make('dt', {}, "Last seen:"));
1859					dl.appendChild(make('dd', {'class': 'lastSeen'}, data._lastSeen.toLocaleString()));
1860				}
1861
1862				dl.appendChild(make('dt', {}, "User-Agent:"));
1863				dl.appendChild(make('dd', {'class': 'agent'}, data.agent));
1864
1865				dl.appendChild(make('dt', {}, "Languages:"));
1866				dl.appendChild(make('dd', {'class': 'langs'}, "Client accepts: [" + data.accept + "]; Page: [" + data.lang + ']'));
1867
1868				if (data.geo && data.geo !=='') {
1869					dl.appendChild(make('dt', {}, "Location:"));
1870					dl.appendChild(make('dd', {
1871						'class': 'country ctry_' + data.geo.toLowerCase(),
1872						'data-ctry': data.geo,
1873						'title': "Country: " + data._country
1874					}, data._country + ' (' + data.geo + ')'));
1875				}
1876
1877				/*dl.appendChild(make('dt', {}, "Visitor Type:"));
1878				dl.appendChild(make('dd', undefined, data._type ));*/
1879
1880				dl.appendChild(make('dt', {}, "Seen by:"));
1881				dl.appendChild(make('dd', undefined, data._seenBy.join(', ') ));
1882
1883				dl.appendChild(make('dt', {}, "Visited pages:"));
1884				const pagesDd = make('dd', {'class': 'pages'});
1885				const pageList = make('ul');
1886
1887				/* list all page views */
1888				data._pageViews.forEach( (page) => {
1889					const pgLi = make('li');
1890
1891					let visitTimeStr = "Bounce";
1892					const visitDuration = page._lastSeen.getTime() - page._firstSeen.getTime();
1893					if (visitDuration > 0) {
1894						visitTimeStr = Math.floor(visitDuration / 1000) + "s";
1895					}
1896
1897					pgLi.appendChild(make('span', {}, page.pg)); /* DW Page ID */
1898					if (page._ref) {
1899						pgLi.appendChild(make('span', {
1900							'data-ref': page._ref.host,
1901							'title': "Referrer: " + page._ref.full
1902						}, page._ref.site));
1903					} else {
1904						pgLi.appendChild(make('span', {
1905						}, "No referer"));
1906					}
1907					pgLi.appendChild(make('span', {}, ( page._seenBy ? page._seenBy.join(', ') : '—') + '; ' + page._tickCount));
1908					pgLi.appendChild(make('span', {}, BotMon.t._formatTime(page._firstSeen)));
1909
1910					// get the time difference:
1911					const tDiff = BotMon.t._formatTimeDiff(page._firstSeen, page._lastSeen);
1912					if (tDiff) {
1913						pgLi.appendChild(make('span', {'class': 'visit-length', 'title': 'Last seen: ' + page._lastSeen.toLocaleString()}, tDiff));
1914					} else {
1915						pgLi.appendChild(make('span', {
1916							'class': 'bounce',
1917							'title': "Visitor bounced"}, "Bounce"));
1918					}
1919
1920					pageList.appendChild(pgLi);
1921				});
1922				pagesDd.appendChild(pageList);
1923				dl.appendChild(pagesDd);
1924
1925				/* bot evaluation rating */
1926				if (data._type !== BM_USERTYPE.KNOWN_BOT && data._type !== BM_USERTYPE.KNOWN_USER) {
1927					dl.appendChild(make('dt', undefined, "Bot rating:"));
1928					dl.appendChild(make('dd', {'class': 'bot-rating'}, ( data._botVal ? data._botVal : '–' ) + ' (of ' + BotMon.live.data.rules._threshold + ')'));
1929
1930					/* add bot evaluation details: */
1931					if (data._eval) {
1932						dl.appendChild(make('dt', {}, "Bot evaluation details:"));
1933						const evalDd = make('dd');
1934						const testList = make('ul',{
1935							'class': 'eval'
1936						});
1937						data._eval.forEach( test => {
1938
1939							const tObj = BotMon.live.data.rules.getRuleInfo(test);
1940							let tDesc = tObj ? tObj.desc : test;
1941
1942							// special case for Bot IP range test:
1943							if (tObj.func == 'fromKnownBotIP') {
1944								const rangeInfo = BotMon.live.data.rules.getBotIPInfo(data.ip);
1945								if (rangeInfo) {
1946									tDesc += ' (' + (rangeInfo.label ? rangeInfo.label : 'Unknown') + ')';
1947								}
1948							}
1949
1950							// create the entry field
1951							const tstLi = make('li');
1952							tstLi.appendChild(make('span', {
1953								'data-testid': test
1954							}, tDesc));
1955							tstLi.appendChild(make('span', {}, ( tObj ? tObj.bot : '—') ));
1956							testList.appendChild(tstLi);
1957						});
1958
1959						// add total row
1960						const tst2Li = make('li', {
1961							'class': 'total'
1962						});
1963						/*tst2Li.appendChild(make('span', {}, "Total:"));
1964						tst2Li.appendChild(make('span', {}, data._botVal));
1965						testList.appendChild(tst2Li);*/
1966
1967						evalDd.appendChild(testList);
1968						dl.appendChild(evalDd);
1969					}
1970				}
1971				// return the element to add to the UI:
1972				return dl;
1973			}
1974
1975		}
1976	}
1977};
1978
1979/* launch only if the BotMon admin panel is open: */
1980if (document.getElementById('botmon__admin')) {
1981	BotMon.init();
1982}