xref: /plugin/botmon/admin.js (revision e56d7b71abb5d1a6e19cb8d0b2e4e35a95ea2380)
1"use strict";
2/* DokuWiki BotMon Plugin Script file */
3/* 14.10.2025 - 0.5.0 - pre-release */
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	'PROBABLY_HUMAN': 'human',
11	'LIKELY_BOT': 'likely_bot',
12	'KNOWN_BOT': 'known_bot'
13});
14
15// enumeration of log types:
16const BM_LOGTYPE = Object.freeze({
17	'SERVER': 'srv',
18	'CLIENT': 'log',
19	'TICKER': 'tck'
20});
21
22// enumeration of IP versions:
23const BM_IPVERSION = Object.freeze({
24	'IPv4': 4,
25	'IPv6': 6
26});
27
28/* BotMon root object */
29const BotMon = {
30
31	init: function() {
32		//console.info('BotMon.init()');
33
34		// find the plugin basedir:
35		this._baseDir = document.currentScript.src.substring(0, document.currentScript.src.indexOf('/exe/'))
36			+ '/plugins/botmon/';
37
38		// read the page language from the DOM:
39		this._lang = document.getRootNode().documentElement.lang || this._lang;
40
41		// get the time offset:
42		this._timeDiff = BotMon.t._getTimeOffset();
43
44		// get yesterday's date:
45		let d = new Date();
46		d.setDate(d.getDate() - 1);
47		this._datestr = d.toISOString().slice(0, 10);
48
49		// init the sub-objects:
50		BotMon.t._callInit(this);
51	},
52
53	_baseDir: null,
54	_lang: 'en',
55	_datestr: '',
56	_timeDiff: '',
57
58	/* internal tools */
59	t: {
60		/* helper function to call inits of sub-objects */
61		_callInit: function(obj) {
62			//console.info('BotMon.t._callInit(obj=',obj,')');
63
64			/* call init / _init on each sub-object: */
65			Object.keys(obj).forEach( (key,i) => {
66				const sub = obj[key];
67				let init = null;
68				if (typeof sub === 'object' && sub.init) {
69					init = sub.init;
70				}
71
72				// bind to object
73				if (typeof init == 'function') {
74					const init2 = init.bind(sub);
75					init2(obj);
76				}
77			});
78		},
79
80		/* helper function to calculate the time difference to UTC: */
81		_getTimeOffset: function() {
82			const now = new Date();
83			let offset = now.getTimezoneOffset(); // in minutes
84			const sign = Math.sign(offset); // +1 or -1
85			offset = Math.abs(offset); // always positive
86
87			let hours = 0;
88			while (offset >= 60) {
89				hours += 1;
90				offset -= 60;
91			}
92			return ( hours > 0 ? sign * hours + ' h' : '') + (offset > 0 ? ` ${offset} min` : '');
93		},
94
95		/* helper function to create a new element with all attributes and text content */
96		_makeElement: function(name, atlist = undefined, text = undefined) {
97			var r = null;
98			try {
99				r = document.createElement(name);
100				if (atlist) {
101					for (let attr in atlist) {
102						r.setAttribute(attr, atlist[attr]);
103					}
104				}
105				if (text) {
106					r.textContent = text.toString();
107				}
108			} catch(e) {
109				console.error(e);
110			}
111			return r;
112		},
113
114		/* helper to convert an ip address string to a normalised format: */
115		_ip2Num: function(ip) {
116			if (!ip) {
117				return 'null';
118			} else if (ip.indexOf(':') > 0) { /* IP6 */
119				return (ip.split(':').map(d => ('0000'+d).slice(-4) ).join(':'));
120			} else { /* IP4 */
121				return ip.split('.').map(d => ('000'+d).slice(-3) ).join('.');
122			}
123		},
124
125		/* helper function to format a Date object to show only the time. */
126		/* returns String */
127		_formatTime: function(date) {
128
129			if (date) {
130				return date.getHours() + ':' + ('0'+date.getMinutes()).slice(-2) + ':' + ('0'+date.getSeconds()).slice(-2);
131			} else {
132				return null;
133			}
134
135		},
136
137		/* helper function to show a time difference in seconds or minutes */
138		/* returns String */
139		_formatTimeDiff: function(dateA, dateB) {
140
141			// if the second date is ealier, swap them:
142			if (dateA > dateB) dateB = [dateA, dateA = dateB][0];
143
144			// get the difference in milliseconds:
145			let ms = dateB - dateA;
146
147			if (ms > 50) { /* ignore small time spans */
148				const h = Math.floor((ms / (1000 * 60 * 60)) % 24);
149				const m = Math.floor((ms / (1000 * 60)) % 60);
150				const s = Math.floor((ms / 1000) % 60);
151
152				return ( h>0 ? h + 'h ': '') + ( m>0 ? m + 'm ': '') + ( s>0 ? s + 's': '');
153			}
154
155			return null;
156
157		},
158
159		// calcualte a reduced ration between two numbers
160		// adapted from https://stackoverflow.com/questions/3946373/math-in-js-how-do-i-get-a-ratio-from-a-percentage
161		_getRatio: function(a, b, tolerance) {
162
163			var bg = b;
164			var sm = a;
165			if (a > b) {
166				var bg = a;
167				var sm = b;
168			}
169
170			for (var i = 1; i < 1000000; i++) {
171				var d = sm / i;
172				var res = bg / d;
173				var howClose = Math.abs(res - res.toFixed(0));
174				if (howClose < tolerance) {
175					if (a > b) {
176						return res.toFixed(0) + ':' + i;
177					} else {
178						return i + ':' + res.toFixed(0);
179					}
180				}
181			}
182		}
183	}
184};
185
186/* everything specific to the "Latest" tab is self-contained in the "live" object: */
187BotMon.live = {
188	init: function() {
189		//console.info('BotMon.live.init()');
190
191		// set the title:
192		const tDiff = '<abbr title="Coordinated Universal Time">UTC</abbr> ' + (BotMon._timeDiff != '' ? ` (offset: ${BotMon._timeDiff}` : '' ) + ')';
193		BotMon.live.gui.status.setTitle(`Data for <time datetime="${BotMon._datestr}">${BotMon._datestr}</time> ${tDiff}`);
194
195		// init sub-objects:
196		BotMon.t._callInit(this);
197	},
198
199	data: {
200		init: function() {
201			//console.info('BotMon.live.data.init()');
202
203			// call sub-inits:
204			BotMon.t._callInit(this);
205		},
206
207		// this will be called when the known json files are done loading:
208		_dispatch: function(file) {
209			//console.info('BotMon.live.data._dispatch(,',file,')');
210
211			// shortcut to make code more readable:
212			const data = BotMon.live.data;
213
214			// set the flags:
215			switch(file) {
216				case 'rules':
217					data._dispatchRulesLoaded = true;
218					break;
219				case 'ipranges':
220					data._dispatchIPRangesLoaded = true;
221					break;
222				case 'bots':
223					data._dispatchBotsLoaded = true;
224					break;
225				case 'clients':
226					data._dispatchClientsLoaded = true;
227					break;
228				case 'platforms':
229					data._dispatchPlatformsLoaded = true;
230					break;
231				default:
232					// ignore
233			}
234
235			// are all the flags set?
236			if (data._dispatchBotsLoaded && data._dispatchClientsLoaded && data._dispatchPlatformsLoaded && data._dispatchRulesLoaded && data._dispatchIPRangesLoaded) {
237				// chain the log files loading:
238				BotMon.live.data.loadLogFile(BM_LOGTYPE.SERVER, BotMon.live.data._onServerLogLoaded);
239			}
240		},
241		// flags to track which data files have been loaded:
242		_dispatchBotsLoaded: false,
243		_dispatchClientsLoaded: false,
244		_dispatchPlatformsLoaded: false,
245		_dispatchIPRangesLoaded: false,
246		_dispatchRulesLoaded: false,
247
248		// event callback, after the server log has been loaded:
249		_onServerLogLoaded: function() {
250			//console.info('BotMon.live.data._onServerLogLoaded()');
251
252			// chain the client log file to load:
253			BotMon.live.data.loadLogFile(BM_LOGTYPE.CLIENT, BotMon.live.data._onClientLogLoaded);
254		},
255
256		// event callback, after the client log has been loaded:
257		_onClientLogLoaded: function() {
258			//console.info('BotMon.live.data._onClientLogLoaded()');
259
260			// chain the ticks file to load:
261			BotMon.live.data.loadLogFile(BM_LOGTYPE.TICKER, BotMon.live.data._onTicksLogLoaded);
262
263		},
264
265		// event callback, after the tiker log has been loaded:
266		_onTicksLogLoaded: function() {
267			//console.info('BotMon.live.data._onTicksLogLoaded()');
268
269			// analyse the data:
270			BotMon.live.data.analytics.analyseAll();
271
272			// sort the data:
273			// #TODO
274
275			// display the data:
276			BotMon.live.gui.overview.make();
277
278			//console.log(BotMon.live.data.model._visitors);
279
280		},
281
282		// the data model:
283		model: {
284			// visitors storage:
285			_visitors: [],
286
287			// find an already existing visitor record:
288			findVisitor: function(visitor, type) {
289				//console.info('BotMon.live.data.model.findVisitor()', type);
290				//console.log(visitor);
291
292				// shortcut to make code more readable:
293				const model = BotMon.live.data.model;
294
295				const timeout = 60 * 60 * 1000; // session timeout: One hour
296
297				if (visitor._type == BM_USERTYPE.KNOWN_BOT) { // known bots match by their bot ID:
298
299					for (let i=0; i<model._visitors.length; i++) {
300						const v = model._visitors[i];
301
302						// bots match when their ID matches:
303						if (v._bot && v._bot.id == visitor._bot.id) {
304							return v;
305						}
306					}
307				} else { // other types match by their DW/PHPIDs:
308
309					// loop over all visitors already registered and check for ID matches:
310					for (let i=0; i<model._visitors.length; i++) {
311						const v = model._visitors[i];
312
313						if ( v.id == visitor.id) { // match the DW/PHP IDs
314							return v;
315						}
316					}
317
318					// if not found, try to match IP address and user agent:
319					for (let i=0; i<model._visitors.length; i++) {
320						const v = model._visitors[i];
321						if (  v.ip == visitor.ip && v.agent == visitor.agent) {
322							return v;
323						}
324					}
325				}
326
327				return null; // nothing found
328			},
329
330			/* if there is already this visit registered, return the page view item */
331			_getPageView: function(visit, view) {
332
333				// shortcut to make code more readable:
334				const model = BotMon.live.data.model;
335
336				for (let i=0; i<visit._pageViews.length; i++) {
337					const pv = visit._pageViews[i];
338					if (pv.pg == view.pg) {
339						return pv;
340					}
341				}
342				return null; // not found
343			},
344
345			// register a new visitor (or update if already exists)
346			registerVisit: function(nv, type) {
347				//console.info('registerVisit', nv, type);
348
349				// shortcut to make code more readable:
350				const model = BotMon.live.data.model;
351
352				// is it a known bot?
353				const bot = BotMon.live.data.bots.match(nv.agent);
354
355				// enrich new visitor with relevant data:
356				if (!nv._bot) nv._bot = bot ?? null; // bot info
357				nv._type = ( bot ? BM_USERTYPE.KNOWN_BOT : ( nv.usr && nv.usr !== '' ? BM_USERTYPE.KNOWN_USER : BM_USERTYPE.UNKNOWN ) ); // user type
358				if (bot && bot.geo) {
359					if (!nv.geo || nv.geo == '' || nv.geo == 'ZZ') nv.geo = bot.geo;
360				} else if (!nv.geo ||nv.geo == '') {
361					nv.geo = 'ZZ';
362				}
363
364				// update first and last seen:
365				if (!nv._firstSeen) nv._firstSeen = nv.ts; // first-seen
366				nv._lastSeen = nv.ts; // last-seen
367
368				// country name:
369				try {
370					nv._country = ( nv.geo == 'local' ? "localhost" : "Unknown" );
371					if (nv.geo && nv.geo !== '' && nv.geo !== 'ZZ' && nv.geo !== 'local') {
372						const countryName = new Intl.DisplayNames(['en', BotMon._lang], {type: 'region'});
373						nv._country = countryName.of(nv.geo.substring(0,2)) ?? nv.geo;
374					}
375				} catch (err) {
376					console.error(err);
377					nv._country = 'Error';
378				}
379
380				// check if it already exists:
381				let visitor = model.findVisitor(nv, type);
382				if (!visitor) {
383					visitor = nv;
384					visitor._seenBy = [type];
385					visitor._pageViews = []; // array of page views
386					visitor._hasReferrer = false; // has at least one referrer
387					visitor._jsClient = false; // visitor has been seen logged by client js as well
388					visitor._client = BotMon.live.data.clients.match(nv.agent) ?? null; // client info
389					visitor._platform = BotMon.live.data.platforms.match(nv.agent); // platform info
390					model._visitors.push(visitor);
391				} else { // update existing
392					if (visitor._firstSeen > nv.ts) {
393						visitor._firstSeen = nv.ts;
394					}
395				}
396
397				// find browser
398
399				// is this visit already registered?
400				let prereg = model._getPageView(visitor, nv);
401				if (!prereg) {
402					// add new page view:
403					prereg = model._makePageView(nv, type);
404					visitor._pageViews.push(prereg);
405				} else {
406					// update last seen date
407					prereg._lastSeen = nv.ts;
408					// increase view count:
409					prereg._viewCount += 1;
410					prereg._tickCount += 1;
411				}
412
413				// update referrer state:
414				visitor._hasReferrer = visitor._hasReferrer ||
415					(prereg.ref !== undefined && prereg.ref !== '');
416
417				// update time stamp for last-seen:
418				if (visitor._lastSeen < nv.ts) {
419					visitor._lastSeen = nv.ts;
420				}
421
422				// if needed:
423				return visitor;
424			},
425
426			// updating visit data from the client-side log:
427			updateVisit: function(dat) {
428				//console.info('updateVisit', dat);
429
430				// shortcut to make code more readable:
431				const model = BotMon.live.data.model;
432
433				const type = BM_LOGTYPE.CLIENT;
434
435				let visitor = BotMon.live.data.model.findVisitor(dat, type);
436				if (!visitor) {
437					visitor = model.registerVisit(dat, type);
438				}
439				if (visitor) {
440
441					if (visitor._lastSeen < dat.ts) {
442						visitor._lastSeen = dat.ts;
443					}
444					if (!visitor._seenBy.includes(type)) {
445						visitor._seenBy.push(type);
446					}
447					visitor._jsClient = true; // seen by client js
448				}
449
450				// find the page view:
451				let prereg = BotMon.live.data.model._getPageView(visitor, dat);
452				if (prereg) {
453					// update the page view:
454					prereg._lastSeen = dat.ts;
455					if (!prereg._seenBy.includes(type)) prereg._seenBy.push(type);
456					prereg._jsClient = true; // seen by client js
457				} else {
458					// add the page view to the visitor:
459					prereg = model._makePageView(dat, type);
460					visitor._pageViews.push(prereg);
461				}
462				prereg._tickCount += 1;
463			},
464
465			// updating visit data from the ticker log:
466			updateTicks: function(dat) {
467				//console.info('updateTicks', dat);
468
469				// shortcut to make code more readable:
470				const model = BotMon.live.data.model;
471
472				const type = BM_LOGTYPE.TICKER;
473
474				// find the visit info:
475				let visitor = model.findVisitor(dat, type);
476				if (!visitor) {
477					console.info(`No visitor with ID “${dat.id}” found, registering as a new one.`);
478					visitor = model.registerVisit(dat, type);
479				}
480				if (visitor) {
481					// update visitor:
482					if (visitor._lastSeen < dat.ts) visitor._lastSeen = dat.ts;
483					if (!visitor._seenBy.includes(type)) visitor._seenBy.push(type);
484
485					// get the page view info:
486					let pv = model._getPageView(visitor, dat);
487					if (!pv) {
488						console.info(`No page view for visit ID “${dat.id}”, page “${dat.pg}”, registering a new one.`);
489						pv = model._makePageView(dat, type);
490						visitor._pageViews.push(pv);
491					}
492
493					// update the page view info:
494					if (!pv._seenBy.includes(type)) pv._seenBy.push(type);
495					if (pv._lastSeen.getTime() < dat.ts.getTime()) pv._lastSeen = dat.ts;
496					pv._tickCount += 1;
497
498				}
499			},
500
501			// helper function to create a new "page view" item:
502			_makePageView: function(data, type) {
503				// console.info('_makePageView', data);
504
505				// try to parse the referrer:
506				let rUrl = null;
507				try {
508					rUrl = ( data.ref && data.ref !== '' ? new URL(data.ref) : null );
509				} catch (e) {
510					console.warn(`Invalid referer: “${data.ref}”.`);
511					console.info(data);
512				}
513
514				return {
515					_by: type,
516					ip: data.ip,
517					pg: data.pg,
518					lang: data.lang || '??',
519					_ref: rUrl,
520					_firstSeen: data.ts,
521					_lastSeen: data.ts,
522					_seenBy: [type],
523					_jsClient: ( type !== BM_LOGTYPE.SERVER),
524					_viewCount: 1,
525					_tickCount: 0
526				};
527			}
528		},
529
530		// functions to analyse the data:
531		analytics: {
532
533			/**
534			 * Initializes the analytics data storage object:
535			 */
536			init: function() {
537				//console.info('BotMon.live.data.analytics.init()');
538			},
539
540			// data storage:
541			data: {
542				totalVisits: 0,
543				totalPageViews: 0,
544				humanPageViews: 0,
545				bots: {
546					known: 0,
547					suspected: 0,
548					human: 0,
549					users: 0
550				}
551			},
552
553			// sort the visits by type:
554			groups: {
555				knownBots: [],
556				suspectedBots: [],
557				humans: [],
558				users: []
559			},
560
561			// all analytics
562			analyseAll: function() {
563				//console.info('BotMon.live.data.analytics.analyseAll()');
564
565				// shortcut to make code more readable:
566				const model = BotMon.live.data.model;
567				const me = BotMon.live.data.analytics;
568
569				BotMon.live.gui.status.showBusy("Analysing data …");
570
571				// loop over all visitors:
572				model._visitors.forEach( (v) => {
573
574					// count visits and page views:
575					this.data.totalVisits += 1;
576					this.data.totalPageViews += v._pageViews.length;
577
578					// check for typical bot aspects:
579					let botScore = 0;
580
581					if (v._type == BM_USERTYPE.KNOWN_BOT) { // known bots
582
583						this.data.bots.known += v._pageViews.length;
584						this.groups.knownBots.push(v);
585
586					} else if (v._type == BM_USERTYPE.KNOWN_USER) { // known users */
587
588						this.data.bots.users += v._pageViews.length;
589						this.groups.users.push(v);
590
591					} else {
592
593						// get evaluation:
594						const e = BotMon.live.data.rules.evaluate(v);
595						v._eval = e.rules;
596						v._botVal = e.val;
597
598						if (e.isBot) { // likely bots
599							v._type = BM_USERTYPE.LIKELY_BOT;
600							this.data.bots.suspected += v._pageViews.length;
601							this.groups.suspectedBots.push(v);
602						} else { // probably humans
603							v._type = BM_USERTYPE.PROBABLY_HUMAN;
604							this.data.bots.human += v._pageViews.length;
605							this.groups.humans.push(v);
606						}
607					}
608
609					// perform actions depending on the visitor type:
610					if (v._type == BM_USERTYPE.KNOWN_BOT ) { /* known bots only */
611
612					} else if (v._type == BM_USERTYPE.LIKELY_BOT) { /* probable bots only */
613
614						// add bot views to IP range information:
615						me.addToIpRanges(v);
616
617					} else { /* humans only */
618
619						// add browser and platform statistics:
620						me.addBrowserPlatform(v);
621
622						// add
623						v._pageViews.forEach( pv => {
624							me.addToRefererList(pv._ref);
625							me.addToPagesList(pv.pg);
626						});
627					}
628
629					// add to the country lists:
630					me.addToCountries(v.geo, v._country, v._type);
631
632				});
633
634				BotMon.live.gui.status.hideBusy('Done.');
635			},
636
637			// get a list of known bots:
638			getTopBots: function(max) {
639				//console.info('BotMon.live.data.analytics.getTopBots('+max+')');
640
641				//console.log(BotMon.live.data.analytics.groups.knownBots);
642
643				let botsList = BotMon.live.data.analytics.groups.knownBots.toSorted( (a, b) => {
644					return b._pageViews.length - a._pageViews.length;
645				});
646
647				const other = {
648					'id': 'other',
649					'name': "Others",
650					'count': 0
651				};
652
653				const rList = [];
654				const max2 = ( botsList.length > max ? max-1 : botsList.length );
655				let total = 0; // adding up the items
656				for (let i=0; i<botsList.length; i++) {
657					const it = botsList[i];
658					if (it && it._bot) {
659						if (i < max2) {
660							rList.push({
661								id: it._bot.id,
662								name: (it._bot.n ? it._bot.n : it._bot.id),
663								count: it._pageViews.length
664							});
665						} else {
666							other.count += it._pageViews.length;
667						};
668						total += it._pageViews.length;
669					}
670				};
671
672				// add the "other" item, if needed:
673				if (botsList.length > max2) {
674					rList.push(other);
675				};
676
677				rList.forEach( it => {
678					it.pct = (it.count * 100 / total);
679				});
680
681				return rList;
682			},
683
684			// most visited pages list:
685			_pagesList: [],
686
687			/**
688			 * Add a page view to the list of most visited pages.
689			 * @param {string} pageId - The page ID to add to the list.
690			 * @example
691			 * BotMon.live.data.analytics.addToPagesList('1234567890');
692			 */
693			addToPagesList: function(pageId) {
694				//console.log('BotMon.live.data.analytics.addToPagesList', pageId);
695
696				const me = BotMon.live.data.analytics;
697
698				// already exists?
699				let pgObj = null;
700				for (let i = 0; i < me._pagesList.length; i++) {
701					if (me._pagesList[i].id == pageId) {
702						pgObj = me._pagesList[i];
703						break;
704					}
705				}
706
707				// if not exists, create it:
708				if (!pgObj) {
709					pgObj = {
710						id: pageId,
711						count: 1
712					};
713					me._pagesList.push(pgObj);
714				} else {
715					pgObj.count += 1;
716				}
717			},
718
719			getTopPages: function(max) {
720				//console.info('BotMon.live.data.analytics.getTopPages('+max+')');
721				const me = BotMon.live.data.analytics;
722				return me._pagesList.toSorted( (a, b) => {
723					return b.count - a.count;
724				}).slice(0,max);
725			},
726
727			// Referer List:
728			_refererList: [],
729
730			addToRefererList: function(ref) {
731				//console.log('BotMon.live.data.analytics.addToRefererList',ref);
732
733				const me = BotMon.live.data.analytics;
734
735				// ignore internal references:
736				if (ref && ref.host == window.location.host) {
737					return;
738				}
739
740				const refInfo = me.getRefererInfo(ref);
741
742				// already exists?
743				let refObj = null;
744				for (let i = 0; i < me._refererList.length; i++) {
745					if (me._refererList[i].id == refInfo.id) {
746						refObj = me._refererList[i];
747						break;
748					}
749				}
750
751				// if not exists, create it:
752				if (!refObj) {
753					refObj = refInfo;
754					refObj.count = 1;
755					me._refererList.push(refObj);
756				} else {
757					refObj.count += 1;
758				}
759			},
760
761			getRefererInfo: function(url) {
762				//console.log('BotMon.live.data.analytics.getRefererInfo',url);
763				try {
764					url = new URL(url);
765				} catch (e) {
766					return {
767						'id': 'null',
768						'n': 'Invalid Referer'
769					};
770				}
771
772				// find the referer ID:
773				let refId = 'null';
774				let refName = 'No Referer';
775				if (url && url.host) {
776					const hArr = url.host.split('.');
777					const tld = hArr[hArr.length-1];
778					refId = ( tld == 'localhost' ? tld : hArr[hArr.length-2]);
779					refName = hArr[hArr.length-2] + '.' + tld;
780				}
781
782				return {
783					'id': refId,
784					'n': refName
785				};
786			},
787
788			/**
789			 * Get a sorted list of the top referers.
790			 * The list is sorted in descending order of count.
791			 * If the array has more items than the given maximum, the rest of the items are added to an "other" item.
792			 * Each item in the list has a "pct" property, which is the percentage of the total count.
793			 * @param {number} max - The maximum number of items to return.
794			 * @return {Array} The sorted list of top referers.
795			 */
796			getTopReferers: function(max) {
797				//console.info(('BotMon.live.data.analytics.getTopReferers(' + max + ')'));
798
799				const me = BotMon.live.data.analytics;
800
801				return me._makeTopList(me._refererList, max);
802			},
803
804			/**
805			 * Create a sorted list of top items from a given array.
806			 * The list is sorted in descending order of count.
807			 * If the array has more items than the given maximum, the rest of the items are added to an "other" item.
808			 * Each item in the list has a "pct" property, which is the percentage of the total count.
809			 * @param {Array} arr - The array to sort and truncate.
810			 * @param {number} max - The maximum number of items to return.
811			 * @return {Array} The sorted list of top items.
812			 */
813			_makeTopList: function(arr, max) {
814				//console.info(('BotMon.live.data.analytics._makeTopList(arr,' + max + ')'));
815
816				const me = BotMon.live.data.analytics;
817
818				// sort the list:
819				arr.sort( (a,b) => {
820					return b.count - a.count;
821				});
822
823				const rList = []; // return array
824				const max2 = ( arr.length >= max ? max-1 : arr.length );
825				const other = {
826					'id': 'other',
827					'name': "Others",
828					'count': 0
829				};
830				let total = 0; // adding up the items
831				for (let i=0; Math.min(max, arr.length) > i; i++) {
832					const it = arr[i];
833					if (it) {
834						if (i < max2) {
835							const rIt = {
836								id: it.id,
837								name: (it.n ? it.n : it.id),
838								count: it.count
839							};
840							rList.push(rIt);
841						} else {
842							other.count += it.count;
843						}
844						total += it.count;
845					}
846				}
847
848				// add the "other" item, if needed:
849				if (arr.length > max2) {
850					rList.push(other);
851				};
852
853				rList.forEach( it => {
854					it.pct = (it.count * 100 / total);
855				});
856
857				return rList;
858			},
859
860			/* countries of visits */
861			_countries: {
862				'human': [],
863				'bot': []
864			},
865			/**
866			 * Adds a country code to the statistics.
867			 *
868			 * @param {string} iso The ISO 3166-1 alpha-2 country code.
869			 */
870			addToCountries: function(iso, name, type) {
871
872				const me = BotMon.live.data.analytics;
873
874				// find the correct array:
875				let arr = null;
876				switch (type) {
877
878					case BM_USERTYPE.KNOWN_USER:
879					case BM_USERTYPE.PROBABLY_HUMAN:
880						arr = me._countries.human;
881						break;
882					case BM_USERTYPE.LIKELY_BOT:
883					case BM_USERTYPE.KNOWN_BOT:
884						arr = me._countries.bot;
885						break;
886					default:
887						console.warn(`Unknown user type ${type} in function addToCountries.`);
888				}
889
890				if (arr) {
891					let cRec = arr.find( it => it.id == iso);
892					if (!cRec) {
893						cRec = {
894							'id': iso,
895							'n': name,
896							'count': 1
897						};
898						arr.push(cRec);
899					} else {
900						cRec.count += 1;
901					}
902				}
903			},
904
905			/**
906			 * Returns a list of countries with visit counts, sorted by visit count in descending order.
907			 *
908			 * @param {BM_USERTYPE} type array of types type of visitors to return.
909			 * @param {number} max The maximum number of entries to return.
910			 * @return {Array} A list of objects with properties 'iso' (ISO 3166-1 alpha-2 country code) and 'count' (visit count).
911			 */
912			getCountryList: function(type, max) {
913
914				const me = BotMon.live.data.analytics;
915
916				// find the correct array:
917				let arr = null;
918				switch (type) {
919
920					case 'human':
921						arr = me._countries.human;
922						break;
923					case 'bot':
924						arr = me._countries.bot;
925						break;
926					default:
927						console.warn(`Unknown user type ${type} in function getCountryList.`);
928						return;
929				}
930
931				return me._makeTopList(arr, max);
932			},
933
934			/* browser and platform of human visitors */
935			_browsers: [],
936			_platforms: [],
937
938			addBrowserPlatform: function(visitor) {
939				//console.info('addBrowserPlatform', visitor);
940
941				const me = BotMon.live.data.analytics;
942
943				// add to browsers list:
944				let browserRec = ( visitor._client ? visitor._client : {'id': 'unknown'});
945				if (visitor._client) {
946					let bRec = me._browsers.find( it => it.id == browserRec.id);
947					if (!bRec) {
948						bRec = {
949							id: browserRec.id,
950							n: browserRec.n,
951							count: 1
952						};
953						me._browsers.push(bRec);
954					} else {
955						bRec.count += 1;
956					}
957				}
958
959				// add to platforms list:
960				let platformRec = ( visitor._platform ? visitor._platform : {'id': 'unknown'});
961				if (visitor._platform) {
962					let pRec = me._platforms.find( it => it.id == platformRec.id);
963					if (!pRec) {
964						pRec = {
965							id: platformRec.id,
966							n: platformRec.n,
967							count: 1
968						};
969						me._platforms.push(pRec);
970					} else {
971						pRec.count += 1;
972					}
973				}
974
975			},
976
977			getTopBrowsers: function(max) {
978
979				const me = BotMon.live.data.analytics;
980
981				return me._makeTopList(me._browsers, max);
982			},
983
984			getTopPlatforms: function(max) {
985
986				const me = BotMon.live.data.analytics;
987
988				return me._makeTopList(me._platforms, max);
989			},
990
991			/* bounces are counted, not calculates: */
992			getBounceCount: function(type) {
993
994				const me = BotMon.live.data.analytics;
995				var bounces = 0;
996				const list = me.groups[type];
997
998				list.forEach(it => {
999					bounces += (it._pageViews.length <= 1 ? 1 : 0);
1000				});
1001
1002				return bounces;
1003			},
1004
1005			_ipOwners: [],
1006
1007			/* adds a visit to the ip ranges arrays */
1008			addToIpRanges: function(v) {
1009				//console.info('addToIpRanges', v.ip);
1010
1011				const me = BotMon.live.data.analytics;
1012				const ipRanges = BotMon.live.data.ipRanges;
1013
1014				let isp = 'null'; // default ISP id
1015				let name = 'Unknown'; // default ISP name
1016
1017				// is there already a known IP range assigned?
1018				if (v._ipRange) {
1019					isp = v._ipRange.g;
1020				}
1021
1022				let ispRec = me._ipOwners.find( it => it.id == isp);
1023				if (!ispRec) {
1024					ispRec = {
1025						'id': isp,
1026						'n': ipRanges.getOwner( isp ) || "Unknown",
1027						'count': v._pageViews.length
1028					};
1029					me._ipOwners.push(ispRec);
1030				} else {
1031					ispRec.count += v._pageViews.length;
1032				}
1033			},
1034
1035			getTopBotISPs: function(max) {
1036
1037				const me = BotMon.live.data.analytics;
1038
1039				return me._makeTopList(me._ipOwners, max);
1040			},
1041		},
1042
1043		// information on "known bots":
1044		bots: {
1045			// loads the list of known bots from a JSON file:
1046			init: async function() {
1047				//console.info('BotMon.live.data.bots.init()');
1048
1049				// Load the list of known bots:
1050				BotMon.live.gui.status.showBusy("Loading known bots …");
1051				const url = BotMon._baseDir + 'config/known-bots.json';
1052				try {
1053					const response = await fetch(url);
1054					if (!response.ok) {
1055						throw new Error(`${response.status} ${response.statusText}`);
1056					}
1057
1058					this._list = await response.json();
1059					this._ready = true;
1060
1061				} catch (error) {
1062					BotMon.live.gui.status.setError("Error while loading the known bots file:", error.message);
1063				} finally {
1064					BotMon.live.gui.status.hideBusy("Status: Done.");
1065					BotMon.live.data._dispatch('bots')
1066				}
1067			},
1068
1069			// returns bot info if the clientId matches a known bot, null otherwise:
1070			match: function(agent) {
1071				//console.info('BotMon.live.data.bots.match(',agent,')');
1072
1073				const BotList = BotMon.live.data.bots._list;
1074
1075				// default is: not found!
1076				let botInfo = null;
1077
1078				if (!agent) return null;
1079
1080				// check for known bots:
1081				BotList.find(bot => {
1082					let r = false;
1083					for (let j=0; j<bot.rx.length; j++) {
1084						const rxr = agent.match(new RegExp(bot.rx[j]));
1085						if (rxr) {
1086							botInfo = {
1087								n : bot.n,
1088								id: bot.id,
1089								geo: (bot.geo ? bot.geo : null),
1090								url: bot.url,
1091								v: (rxr.length > 1 ? rxr[1] : -1)
1092							};
1093							r = true;
1094							break;
1095						};
1096					};
1097					return r;
1098				});
1099
1100				// check for unknown bots:
1101				if (!botInfo) {
1102					const botmatch = agent.match(/([\s\d\w\-]*bot|[\s\d\w\-]*crawler|[\s\d\w\-]*spider)[\/\s;\),\\.$]/i);
1103					if(botmatch) {
1104						botInfo = {'id': ( botmatch[1] || "other_" ), 'n': "Other" + ( botmatch[1] ? " (" + botmatch[1] + ")" : "" ) , "bot": botmatch[1] };
1105					}
1106				}
1107
1108				//console.log("botInfo:", botInfo);
1109				return botInfo;
1110			},
1111
1112
1113			// indicates if the list is loaded and ready to use:
1114			_ready: false,
1115
1116			// the actual bot list is stored here:
1117			_list: []
1118		},
1119
1120		// information on known clients (browsers):
1121		clients: {
1122			// loads the list of known clients from a JSON file:
1123			init: async function() {
1124				//console.info('BotMon.live.data.clients.init()');
1125
1126				// Load the list of known bots:
1127				BotMon.live.gui.status.showBusy("Loading known clients");
1128				const url = BotMon._baseDir + 'config/known-clients.json';
1129				try {
1130					const response = await fetch(url);
1131					if (!response.ok) {
1132						throw new Error(`${response.status} ${response.statusText}`);
1133					}
1134
1135					BotMon.live.data.clients._list = await response.json();
1136					BotMon.live.data.clients._ready = true;
1137
1138				} catch (error) {
1139					BotMon.live.gui.status.setError("Error while loading the known clients file: " + error.message);
1140				} finally {
1141					BotMon.live.gui.status.hideBusy("Status: Done.");
1142					BotMon.live.data._dispatch('clients')
1143				}
1144			},
1145
1146			// returns bot info if the user-agent matches a known bot, null otherwise:
1147			match: function(agent) {
1148				//console.info('BotMon.live.data.clients.match(',agent,')');
1149
1150				let match = {"n": "Unknown", "v": -1, "id": 'null'};
1151
1152				if (agent) {
1153					BotMon.live.data.clients._list.find(client => {
1154						let r = false;
1155						for (let j=0; j<client.rx.length; j++) {
1156							const rxr = agent.match(new RegExp(client.rx[j]));
1157							if (rxr) {
1158								match.n = client.n;
1159								match.v = (rxr.length > 1 ? rxr[1] : -1);
1160								match.id = client.id || null;
1161								r = true;
1162								break;
1163							}
1164						}
1165						return r;
1166					});
1167				}
1168
1169				//console.log(match)
1170				return match;
1171			},
1172
1173			// return the browser name for a browser ID:
1174			getName: function(id) {
1175				const it = BotMon.live.data.clients._list.find(client => client.id == id);
1176				return ( it && it.n ? it.n : "Unknown"); //it.n;
1177			},
1178
1179			// indicates if the list is loaded and ready to use:
1180			_ready: false,
1181
1182			// the actual bot list is stored here:
1183			_list: []
1184
1185		},
1186
1187		// information on known platforms (operating systems):
1188		platforms: {
1189			// loads the list of known platforms from a JSON file:
1190			init: async function() {
1191				//console.info('BotMon.live.data.platforms.init()');
1192
1193				// Load the list of known bots:
1194				BotMon.live.gui.status.showBusy("Loading known platforms");
1195				const url = BotMon._baseDir + 'config/known-platforms.json';
1196				try {
1197					const response = await fetch(url);
1198					if (!response.ok) {
1199						throw new Error(`${response.status} ${response.statusText}`);
1200					}
1201
1202					BotMon.live.data.platforms._list = await response.json();
1203					BotMon.live.data.platforms._ready = true;
1204
1205				} catch (error) {
1206					BotMon.live.gui.status.setError("Error while loading the known platforms file: " + error.message);
1207				} finally {
1208					BotMon.live.gui.status.hideBusy("Status: Done.");
1209					BotMon.live.data._dispatch('platforms')
1210				}
1211			},
1212
1213			// returns bot info if the browser id matches a known platform:
1214			match: function(cid) {
1215				//console.info('BotMon.live.data.platforms.match(',cid,')');
1216
1217				let match = {"n": "Unknown", "id": 'null'};
1218
1219				if (cid) {
1220					BotMon.live.data.platforms._list.find(platform => {
1221						let r = false;
1222						for (let j=0; j<platform.rx.length; j++) {
1223							const rxr = cid.match(new RegExp(platform.rx[j]));
1224							if (rxr) {
1225								match.n = platform.n;
1226								match.v = (rxr.length > 1 ? rxr[1] : -1);
1227								match.id = platform.id || null;
1228								r = true;
1229								break;
1230							}
1231						}
1232						return r;
1233					});
1234				}
1235
1236				return match;
1237			},
1238
1239			// return the platform name for a given ID:
1240			getName: function(id) {
1241				const it = BotMon.live.data.platforms._list.find( pf => pf.id == id);
1242				return ( it ? it.n : 'Unknown' );
1243			},
1244
1245
1246			// indicates if the list is loaded and ready to use:
1247			_ready: false,
1248
1249			// the actual bot list is stored here:
1250			_list: []
1251
1252		},
1253
1254		// storage and functions for the known bot IP-Ranges:
1255		ipRanges: {
1256
1257			init: function() {
1258				//console.log('BotMon.live.data.ipRanges.init()');
1259				// #TODO: Load from separate IP-Ranges file
1260				// load the rules file:
1261				const me = BotMon.live.data;
1262
1263				try {
1264					BotMon.live.data._loadSettingsFile(['user-ipranges', 'known-ipranges'],
1265						(json) => {
1266
1267						// groups can be just saved in the data structure:
1268						if (json.groups && json.groups.constructor.name == 'Array') {
1269							me.ipRanges._groups = json.groups;
1270						}
1271
1272						// groups can be just saved in the data structure:
1273						if (json.ranges && json.ranges.constructor.name == 'Array') {
1274							json.ranges.forEach(range => {
1275								me.ipRanges.add(range);
1276							})
1277						}
1278
1279						// finished loading
1280						BotMon.live.gui.status.hideBusy("Status: Done.");
1281						BotMon.live.data._dispatch('ipranges')
1282					});
1283				} catch (error) {
1284					BotMon.live.gui.status.setError("Error while loading the config file: " + error.message);
1285				}
1286			},
1287
1288			// the actual bot list is stored here:
1289			_list: [],
1290			_groups: [],
1291
1292			add: function(data) {
1293				//console.log('BotMon.live.data.ipRanges.add(',data,')');
1294
1295				const me = BotMon.live.data.ipRanges;
1296
1297				// convert IP address to easier comparable form:
1298				const ip2Num = BotMon.t._ip2Num;
1299
1300				let item = {
1301					'cidr': data.from.replaceAll(/::+/g, '::') + '/' + ( data.m ? data.m : '??' ),
1302					'from': ip2Num(data.from),
1303					'to': ip2Num(data.to),
1304					'm': data.m,
1305					'g': data.g
1306				};
1307				me._list.push(item);
1308
1309			},
1310
1311			getOwner: function(gid) {
1312
1313				const me = BotMon.live.data.ipRanges;
1314
1315				for (let i=0; i < me._groups.length; i++) {
1316					const it = me._groups[i];
1317					if (it.id == gid) {
1318						return it.name;
1319					}
1320				}
1321				return null;
1322			},
1323
1324			match: function(ip) {
1325				//console.log('BotMon.live.data.ipRanges.match(',ip,')');
1326
1327				const me = BotMon.live.data.ipRanges;
1328
1329				// convert IP address to easier comparable form:
1330				const ipNum = BotMon.t._ip2Num(ip);
1331
1332				for (let i=0; i < me._list.length; i++) {
1333					const ipRange = me._list[i];
1334
1335					if (ipNum >= ipRange.from && ipNum <= ipRange.to) {
1336						return ipRange;
1337					}
1338
1339				};
1340				return null;
1341			}
1342		},
1343
1344		// storage for the rules and related functions
1345		rules: {
1346
1347			/**
1348			 * Initializes the rules data.
1349			 *
1350			 * Loads the default config file and the user config file (if present).
1351			 * The default config file is used if the user config file does not have a certain setting.
1352			 * The user config file can override settings from the default config file.
1353			 *
1354			 * The rules are loaded from the `rules` property of the config files.
1355			 * The IP ranges are loaded from the `ipRanges` property of the config files.
1356			 *
1357			 * If an error occurs while loading the config file, it is displayed in the status bar.
1358			 * After the config file is loaded, the status bar is hidden.
1359			 */
1360			init: async function() {
1361				//console.info('BotMon.live.data.rules.init()');
1362
1363				// Load the list of known bots:
1364				BotMon.live.gui.status.showBusy("Loading list of rules …");
1365
1366				// load the rules file:
1367				const me = BotMon.live.data;
1368
1369				try {
1370					BotMon.live.data._loadSettingsFile(['user-config', 'default-config'],
1371						(json) => {
1372
1373							// override the threshold?
1374							if (json.threshold) me._threshold = json.threshold;
1375
1376							// set the rules list:
1377							if (json.rules && json.rules.constructor.name == 'Array') {
1378								me.rules._rulesList = json.rules;
1379							}
1380
1381							BotMon.live.gui.status.hideBusy("Status: Done.");
1382							BotMon.live.data._dispatch('rules')
1383						}
1384					);
1385				} catch (error) {
1386					BotMon.live.gui.status.setError("Error while loading the config file: " + error.message);
1387				}
1388			},
1389
1390			_rulesList: [], // list of rules to find out if a visitor is a bot
1391			_threshold: 100, // above this, it is considered a bot.
1392
1393			// returns a descriptive text for a rule id
1394			getRuleInfo: function(ruleId) {
1395				// console.info('getRuleInfo', ruleId);
1396
1397				// shortcut for neater code:
1398				const me = BotMon.live.data.rules;
1399
1400				for (let i=0; i<me._rulesList.length; i++) {
1401					const rule = me._rulesList[i];
1402					if (rule.id == ruleId) {
1403						return rule;
1404					}
1405				}
1406				return null;
1407
1408			},
1409
1410			// evaluate a visitor for lkikelihood of being a bot
1411			evaluate: function(visitor) {
1412
1413				// shortcut for neater code:
1414				const me = BotMon.live.data.rules;
1415
1416				let r =  {	// evaluation result
1417					'val': 0,
1418					'rules': [],
1419					'isBot': false
1420				};
1421
1422				for (let i=0; i<me._rulesList.length; i++) {
1423					const rule = me._rulesList[i];
1424					const params = ( rule.params ? rule.params : [] );
1425
1426					if (rule.func) { // rule is calling a function
1427						if (me.func[rule.func]) {
1428							if(me.func[rule.func](visitor, ...params)) {
1429								r.val += rule.bot;
1430								r.rules.push(rule.id)
1431							}
1432						} else {
1433							//console.warn("Unknown rule function: “${rule.func}”. Ignoring rule.")
1434						}
1435					}
1436				}
1437
1438				// is a bot?
1439				r.isBot = (r.val >= me._threshold);
1440
1441				return r;
1442			},
1443
1444			// list of functions that can be called by the rules list to evaluate a visitor:
1445			func: {
1446
1447				// check if client is on the list passed as parameter:
1448				matchesClient: function(visitor, ...clients) {
1449
1450					const clientId = ( visitor._client ? visitor._client.id : '');
1451					return clients.includes(clientId);
1452				},
1453
1454				// check if OS/Platform is one of the obsolete ones:
1455				matchesPlatform: function(visitor, ...platforms) {
1456
1457					const pId = ( visitor._platform ? visitor._platform.id : '');
1458
1459					if (visitor._platform.id == null) console.log(visitor._platform);
1460
1461					return platforms.includes(pId);
1462				},
1463
1464				// are there at lest num pages loaded?
1465				smallPageCount: function(visitor, num) {
1466					return (visitor._pageViews.length <= Number(num));
1467				},
1468
1469				// There was no entry in a specific log file for this visitor:
1470				// note that this will also trigger the "noJavaScript" rule:
1471				noRecord: function(visitor, type) {
1472					return !visitor._seenBy.includes(type);
1473				},
1474
1475				// there are no referrers in any of the page visits:
1476				noReferrer: function(visitor) {
1477
1478					let r = false; // return value
1479					for (let i = 0; i < visitor._pageViews.length; i++) {
1480						if (!visitor._pageViews[i]._ref) {
1481							r = true;
1482							break;
1483						}
1484					}
1485					return r;
1486				},
1487
1488				// test for specific client identifiers:
1489				/*matchesClients: function(visitor, ...list) {
1490
1491					for (let i=0; i<list.length; i++) {
1492						if (visitor._client.id == list[i]) {
1493								return true
1494						}
1495					};
1496					return false;
1497				},*/
1498
1499				// unusual combinations of Platform and Client:
1500				combinationTest: function(visitor, ...combinations) {
1501
1502					for (let i=0; i<combinations.length; i++) {
1503
1504						if (visitor._platform.id == combinations[i][0]
1505							&& visitor._client.id == combinations[i][1]) {
1506								return true
1507						}
1508					};
1509
1510					return false;
1511				},
1512
1513				// is the IP address from a known bot network?
1514				fromKnownBotIP: function(visitor) {
1515					//console.info('fromKnownBotIP()', visitor.ip);
1516
1517					const ipInfo = BotMon.live.data.ipRanges.match(visitor.ip);
1518
1519					if (ipInfo) {
1520						visitor._ipInKnownBotRange = true;
1521						visitor._ipRange = ipInfo;
1522					}
1523
1524					return (ipInfo !== null);
1525				},
1526
1527				// is the page language mentioned in the client's accepted languages?
1528				// the parameter holds an array of exceptions, i.e. page languages that should be ignored.
1529				matchLang: function(visitor, ...exceptions) {
1530
1531					if (visitor.lang && visitor.accept && exceptions.indexOf(visitor.lang) < 0) {
1532						return (visitor.accept.split(',').indexOf(visitor.lang) < 0);
1533					}
1534					return false;
1535				},
1536
1537				// the "Accept language" header contains certain entries:
1538				clientAccepts: function(visitor, ...languages) {
1539					//console.info('clientAccepts', visitor.accept, languages);
1540
1541					if (visitor.accept && languages) {;
1542						return ( visitor.accept.split(',').filter(lang => languages.includes(lang)).length > 0 );
1543					}
1544					return false;
1545				},
1546
1547				// Is there an accept-language field defined at all?
1548				noAcceptLang: function(visitor) {
1549
1550					if (!visitor.accept || visitor.accept.length <= 0) { // no accept-languages header
1551						return true;
1552					}
1553					// TODO: parametrize this!
1554					return false;
1555				},
1556				// At least x page views were recorded, but they come within less than y seconds
1557				loadSpeed: function(visitor, minItems, maxTime) {
1558
1559					if (visitor._pageViews.length >= minItems) {
1560						//console.log('loadSpeed', visitor._pageViews.length, minItems, maxTime);
1561
1562						const pvArr = visitor._pageViews.map(pv => pv._lastSeen).sort();
1563
1564						let totalTime = 0;
1565						for (let i=1; i < pvArr.length; i++) {
1566							totalTime += (pvArr[i] - pvArr[i-1]);
1567						}
1568
1569						//console.log('     ', totalTime , Math.round(totalTime / (pvArr.length * 1000)), (( totalTime / pvArr.length ) <= maxTime * 1000), visitor.ip);
1570
1571						return (( totalTime / pvArr.length ) <= maxTime * 1000);
1572					}
1573				},
1574
1575				// Country code matches one of those in the list:
1576				matchesCountry: function(visitor, ...countries) {
1577
1578					// ingore if geoloc is not set or unknown:
1579					if (visitor.geo) {
1580						return (countries.indexOf(visitor.geo) >= 0);
1581					}
1582					return false;
1583				},
1584
1585				// Country does not match one of the given codes.
1586				notFromCountry: function(visitor, ...countries) {
1587
1588					// ingore if geoloc is not set or unknown:
1589					if (visitor.geo && visitor.geo !== 'ZZ') {
1590						return (countries.indexOf(visitor.geo) < 0);
1591					}
1592					return false;
1593				}
1594			}
1595		},
1596
1597		/**
1598		 * Loads a settings file from the specified list of filenames.
1599		 * If the file is successfully loaded, it will call the callback function
1600		 * with the loaded JSON data.
1601		 * If no file can be loaded, it will display an error message.
1602		 *
1603		 * @param {string[]} fns - list of filenames to load
1604		 * @param {function} callback - function to call with the loaded JSON data
1605		 */
1606		_loadSettingsFile: async function(fns, callback) {
1607			//console.info('BotMon.live.data._loadSettingsFile()', fns);
1608
1609			const kJsonExt = '.json';
1610			let loaded = false; // if successfully loaded file
1611
1612			for (let i=0; i<fns.length; i++) {
1613				const filename = fns[i] +kJsonExt;
1614				try {
1615					const response = await fetch(DOKU_BASE + 'lib/plugins/botmon/config/' + filename);
1616					if (!response.ok) {
1617						continue;
1618					} else {
1619						loaded = true;
1620					}
1621					const json = await response.json();
1622					if (callback && typeof callback === 'function') {
1623						callback(json);
1624					}
1625					break;
1626				} catch (e) {
1627					BotMon.live.gui.status.setError("Error while loading the config file: " + filename);
1628				}
1629			}
1630
1631			if (!loaded) {
1632				BotMon.live.gui.status.setError("Could not load a config file.");
1633			}
1634		},
1635
1636		/**
1637		 * Loads a log file (server, page load, or ticker) and parses it.
1638		 * @param {String} type - the type of the log file to load (srv, log, or tck)
1639		 * @param {Function} [onLoaded] - an optional callback function to call after loading is finished.
1640		 */
1641		loadLogFile: async function(type, onLoaded = undefined) {
1642			//console.info('BotMon.live.data.loadLogFile(',type,')');
1643
1644			let typeName = '';
1645			let columns = [];
1646
1647			switch (type) {
1648				case "srv":
1649					typeName = "Server";
1650					columns = ['ts','ip','pg','id','typ','usr','agent','ref','lang','accept','geo'];
1651					break;
1652				case "log":
1653					typeName = "Page load";
1654					columns = ['ts','ip','pg','id','usr','lt','ref','agent'];
1655					break;
1656				case "tck":
1657					typeName = "Ticker";
1658					columns = ['ts','ip','pg','id','agent'];
1659					break;
1660				default:
1661					console.warn(`Unknown log type ${type}.`);
1662					return;
1663			}
1664
1665			// Show the busy indicator and set the visible status:
1666			BotMon.live.gui.status.showBusy(`Loading ${typeName} log file …`);
1667
1668			// compose the URL from which to load:
1669			const url = BotMon._baseDir + `logs/${BotMon._datestr}.${type}.txt`;
1670			//console.log("Loading:",url);
1671
1672			// fetch the data:
1673			try {
1674				const response = await fetch(url);
1675				if (!response.ok) {
1676
1677					throw new Error(`${response.status} ${response.statusText}`);
1678
1679				} else {
1680
1681					// parse the data:
1682					const logtxt = await response.text();
1683					if (logtxt.length <= 0) {
1684						throw new Error(`Empty log file ${url}.`);
1685					}
1686
1687					logtxt.split('\n').forEach((line) => {
1688						if (line.trim() === '') return; // skip empty lines
1689						const cols = line.split('\t');
1690
1691						// assign the columns to an object:
1692						const data = {};
1693						cols.forEach( (colVal,i) => {
1694							colName = columns[i] || `col${i}`;
1695							const colValue = (colName == 'ts' ? new Date(colVal) : colVal.trim());
1696							data[colName] = colValue;
1697						});
1698
1699						// register the visit in the model:
1700						switch(type) {
1701							case BM_LOGTYPE.SERVER:
1702								BotMon.live.data.model.registerVisit(data, type);
1703								break;
1704							case BM_LOGTYPE.CLIENT:
1705								data.typ = 'js';
1706								BotMon.live.data.model.updateVisit(data);
1707								break;
1708							case BM_LOGTYPE.TICKER:
1709								data.typ = 'js';
1710								BotMon.live.data.model.updateTicks(data);
1711								break;
1712							default:
1713								console.warn(`Unknown log type ${type}.`);
1714								return;
1715						}
1716					});
1717				}
1718
1719			} catch (error) {
1720				BotMon.live.gui.status.setError(`Error while loading the ${typeName} log file: ${error.message} – data may be incomplete.`);
1721			} finally {
1722				BotMon.live.gui.status.hideBusy("Status: Done.");
1723				if (onLoaded) {
1724					onLoaded(); // callback after loading is finished.
1725				}
1726			}
1727		}
1728	},
1729
1730	gui: {
1731		init: function() {
1732			// init the lists view:
1733			this.lists.init();
1734		},
1735
1736		/* The Overview / web metrics section of the live tab */
1737		overview: {
1738			/**
1739			 * Populates the overview part of the today tab with the analytics data.
1740			 *
1741			 * @method make
1742			 * @memberof BotMon.live.gui.overview
1743			 */
1744			make: function() {
1745
1746				const data = BotMon.live.data.analytics.data;
1747
1748				const maxItemsPerList = 5; // how many list items to show?
1749
1750				// shortcut for neater code:
1751				const makeElement = BotMon.t._makeElement;
1752
1753				const botsVsHumans = document.getElementById('botmon__today__botsvshumans');
1754				if (botsVsHumans) {
1755					botsVsHumans.appendChild(makeElement('dt', {}, "Page views"));
1756
1757					for (let i = 0; i <= 5; i++) {
1758						const dd = makeElement('dd');
1759						let title = '';
1760						let value = '';
1761						switch(i) {
1762							case 0:
1763								title = "Known bots:";
1764								value = data.bots.known;
1765								break;
1766							case 1:
1767								title = "Suspected bots:";
1768								value = data.bots.suspected;
1769								break;
1770							case 2:
1771								title = "Probably humans:";
1772								value = data.bots.human;
1773								break;
1774							case 3:
1775								title = "Registered users:";
1776								value = data.bots.users;
1777								break;
1778							case 4:
1779								title = "Total:";
1780								value = data.totalPageViews;
1781								break;
1782							case 5:
1783								title = "Bots-humans ratio:";
1784								value = BotMon.t._getRatio(data.bots.suspected + data.bots.known, data.bots.users + data.bots.human, 100);
1785								break;
1786							default:
1787								console.warn(`Unknown list type ${i}.`);
1788						}
1789						dd.appendChild(makeElement('span', {}, title));
1790						dd.appendChild(makeElement('strong', {}, value));
1791						botsVsHumans.appendChild(dd);
1792					}
1793				}
1794
1795				// update known bots list:
1796				const botElement = document.getElementById('botmon__botslist'); /* Known bots */
1797				if (botElement) {
1798					botElement.appendChild(makeElement('dt', {}, `Top known bots`));
1799
1800					let botList = BotMon.live.data.analytics.getTopBots(maxItemsPerList);
1801					botList.forEach( (botInfo) => {
1802						const bli = makeElement('dd');
1803						bli.appendChild(makeElement('span', {'class': 'has_icon bot bot_' + botInfo.id }, botInfo.name));
1804						bli.appendChild(makeElement('span', {'class': 'count' }, botInfo.count));
1805						botElement.append(bli)
1806					});
1807				}
1808
1809				// update the suspected bot IP ranges list:
1810				const botIps = document.getElementById('botmon__botips');
1811				if (botIps) {
1812					botIps.appendChild(makeElement('dt', {}, "Top bot ISPs"));
1813
1814					const ispList = BotMon.live.data.analytics.getTopBotISPs(5);
1815					console.log(ispList);
1816					ispList.forEach( (netInfo) => {
1817						const li = makeElement('dd');
1818						li.appendChild(makeElement('span', {'class': 'has_icon ipaddr ip_' + netInfo.id }, netInfo.name));
1819						li.appendChild(makeElement('span', {'class': 'count' }, netInfo.count));
1820						botIps.append(li)
1821					});
1822				}
1823
1824				// update the top bot countries list:
1825				const botCountries = document.getElementById('botmon__botcountries');
1826				if (botCountries) {
1827					botCountries.appendChild(makeElement('dt', {}, `Top bot Countries`));
1828					const countryList = BotMon.live.data.analytics.getCountryList('bot', 5);
1829					countryList.forEach( (cInfo) => {
1830						const cLi = makeElement('dd');
1831						cLi.appendChild(makeElement('span', {'class': 'has_icon country ctry_' + cInfo.id.toLowerCase() }, cInfo.name));
1832						cLi.appendChild(makeElement('span', {'class': 'count' }, cInfo.count));
1833						botCountries.appendChild(cLi);
1834					});
1835				}
1836
1837				// update the webmetrics overview:
1838				const wmoverview = document.getElementById('botmon__today__wm_overview');
1839				if (wmoverview) {
1840
1841					const humanVisits = BotMon.live.data.analytics.groups.users.length + BotMon.live.data.analytics.groups.humans.length;
1842					const bounceRate = Math.round(100 * (BotMon.live.data.analytics.getBounceCount('users') + BotMon.live.data.analytics.getBounceCount('humans')) / humanVisits);
1843
1844					wmoverview.appendChild(makeElement('dt', {}, "Humans’ metrics"));
1845					for (let i = 0; i <= 4; i++) {
1846						const dd = makeElement('dd');
1847						let title = '';
1848						let value = '';
1849						switch(i) {
1850							case 0:
1851								title = "Page views by registered users:";
1852								value = data.bots.users;
1853								break;
1854							case 1:
1855								title = "Page views by “probably humans”:";
1856								value = data.bots.human;
1857								break;
1858							case 2:
1859								title = "Total human page views:";
1860								value = data.bots.users + data.bots.human;
1861								break;
1862							case 3:
1863								title = "Total human visits:";
1864								value = humanVisits;
1865								break;
1866							case 4:
1867								title = "Humans’ bounce rate:";
1868								value = bounceRate + '%';
1869								break;
1870							default:
1871								console.warn(`Unknown list type ${i}.`);
1872						}
1873						dd.appendChild(makeElement('span', {}, title));
1874						dd.appendChild(makeElement('strong', {}, value));
1875						wmoverview.appendChild(dd);
1876					}
1877				}
1878
1879				// update the webmetrics clients list:
1880				const wmclients = document.getElementById('botmon__today__wm_clients');
1881				if (wmclients) {
1882
1883					wmclients.appendChild(makeElement('dt', {}, "Browsers"));
1884
1885					const clientList = BotMon.live.data.analytics.getTopBrowsers(maxItemsPerList);
1886					if (clientList) {
1887						clientList.forEach( (cInfo) => {
1888							const cDd = makeElement('dd');
1889							cDd.appendChild(makeElement('span', {'class': 'has_icon client cl_' + cInfo.id }, ( cInfo.name ? cInfo.name : cInfo.id)));
1890							cDd.appendChild(makeElement('span', {
1891								'class': 'count',
1892								'title': cInfo.count + " page views"
1893							}, cInfo.pct.toFixed(1) + '%'));
1894							wmclients.appendChild(cDd);
1895						});
1896					}
1897				}
1898
1899				// update the webmetrics platforms list:
1900				const wmplatforms = document.getElementById('botmon__today__wm_platforms');
1901				if (wmplatforms) {
1902
1903					wmplatforms.appendChild(makeElement('dt', {}, "Platforms"));
1904
1905					const pfList = BotMon.live.data.analytics.getTopPlatforms(maxItemsPerList);
1906					if (pfList) {
1907						pfList.forEach( (pInfo) => {
1908							const pDd = makeElement('dd');
1909							pDd.appendChild(makeElement('span', {'class': 'has_icon platform pf_' + pInfo.id }, ( pInfo.name ? pInfo.name : pInfo.id)));
1910							pDd.appendChild(makeElement('span', {
1911								'class': 'count',
1912								'title': pInfo.count + " page views"
1913							}, pInfo.pct.toFixed(1) + '%'));
1914							wmplatforms.appendChild(pDd);
1915						});
1916					}
1917				}
1918
1919				// update the top bot countries list:
1920				const usrCountries = document.getElementById('botmon__today__wm_countries');
1921				if (usrCountries) {
1922					usrCountries.appendChild(makeElement('dt', {}, `Top visitor Countries:`));
1923					const usrCtryList = BotMon.live.data.analytics.getCountryList('human', 5);
1924					usrCtryList.forEach( (cInfo) => {
1925						const cLi = makeElement('dd');
1926						cLi.appendChild(makeElement('span', {'class': 'has_icon country ctry_' + cInfo.id.toLowerCase() }, cInfo.name));
1927						cLi.appendChild(makeElement('span', {'class': 'count' }, cInfo.count));
1928						usrCountries.appendChild(cLi);
1929					});
1930				}
1931
1932				// update the top pages;
1933				const wmpages = document.getElementById('botmon__today__wm_pages');
1934				if (wmpages) {
1935
1936					wmpages.appendChild(makeElement('dt', {}, "Top pages"));
1937
1938					const pgList = BotMon.live.data.analytics.getTopPages(maxItemsPerList);
1939					if (pgList) {
1940						pgList.forEach( (pgInfo) => {
1941							const pgDd = makeElement('dd');
1942							pgDd.appendChild(makeElement('a', {
1943								'class': 'page_icon',
1944								'href': DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pgInfo.id),
1945								'target': 'preview',
1946								'title': "PageID: " + pgInfo.id
1947							}, pgInfo.id));
1948							pgDd.appendChild(makeElement('span', {
1949								'class': 'count',
1950								'title': pgInfo.count + " page views"
1951							}, pgInfo.count));
1952							wmpages.appendChild(pgDd);
1953						});
1954					}
1955				}
1956
1957				// update the top referrers;
1958				const wmreferers = document.getElementById('botmon__today__wm_referers');
1959				if (wmreferers) {
1960
1961					wmreferers.appendChild(makeElement('dt', {}, "Referers"));
1962
1963					const refList = BotMon.live.data.analytics.getTopReferers(maxItemsPerList);
1964					if (refList) {
1965						refList.forEach( (rInfo) => {
1966							const rDd = makeElement('dd');
1967							rDd.appendChild(makeElement('span', {'class': 'has_icon referer ref_' + rInfo.id }, rInfo.name));
1968							rDd.appendChild(makeElement('span', {
1969								'class': 'count',
1970								'title': rInfo.count + " references"
1971							}, rInfo.pct.toFixed(1) + '%'));
1972							wmreferers.appendChild(rDd);
1973						});
1974					}
1975				}
1976			}
1977		},
1978
1979		status: {
1980			setText: function(txt) {
1981				const el = document.getElementById('botmon__today__status');
1982				if (el && BotMon.live.gui.status._errorCount <= 0) {
1983					el.innerText = txt;
1984				}
1985			},
1986
1987			setTitle: function(html) {
1988				const el = document.getElementById('botmon__today__title');
1989				if (el) {
1990					el.innerHTML = html;
1991				}
1992			},
1993
1994			setError: function(txt) {
1995				console.error(txt);
1996				BotMon.live.gui.status._errorCount += 1;
1997				const el = document.getElementById('botmon__today__status');
1998				if (el) {
1999					el.innerText = "Data may be incomplete.";
2000					el.classList.add('error');
2001				}
2002			},
2003			_errorCount: 0,
2004
2005			showBusy: function(txt = null) {
2006				BotMon.live.gui.status._busyCount += 1;
2007				const el = document.getElementById('botmon__today__busy');
2008				if (el) {
2009					el.style.display = 'inline-block';
2010				}
2011				if (txt) BotMon.live.gui.status.setText(txt);
2012			},
2013			_busyCount: 0,
2014
2015			hideBusy: function(txt = null) {
2016				const el = document.getElementById('botmon__today__busy');
2017				BotMon.live.gui.status._busyCount -= 1;
2018				if (BotMon.live.gui.status._busyCount <= 0) {
2019					if (el) el.style.display = 'none';
2020					if (txt) BotMon.live.gui.status.setText(txt);
2021				}
2022			}
2023		},
2024
2025		lists: {
2026			init: function() {
2027
2028				// function shortcut:
2029				const makeElement = BotMon.t._makeElement;
2030
2031				const parent = document.getElementById('botmon__today__visitorlists');
2032				if (parent) {
2033
2034					for (let i=0; i < 4; i++) {
2035
2036						// change the id and title by number:
2037						let listTitle = '';
2038						let listId = '';
2039						let infolink = null;
2040						switch (i) {
2041							case 0:
2042								listTitle = "Registered users";
2043								listId = 'users';
2044								break;
2045							case 1:
2046								listTitle = "Probably humans";
2047								listId = 'humans';
2048								break;
2049							case 2:
2050								listTitle = "Suspected bots";
2051								listId = 'suspectedBots';
2052								infolink = 'https://leib.be/sascha/projects/dokuwiki/botmon/info/suspected_bots';
2053								break;
2054							case 3:
2055								listTitle = "Known bots";
2056								listId = 'knownBots';
2057								infolink = 'https://leib.be/sascha/projects/dokuwiki/botmon/info/known_bots';
2058								break;
2059							default:
2060								console.warn('Unknown list number.');
2061						}
2062
2063						const details = makeElement('details', {
2064							'data-group': listId,
2065							'data-loaded': false
2066						});
2067						const title = details.appendChild(makeElement('summary'));
2068						title.appendChild(makeElement('span', {'class': 'title'}, listTitle));
2069						if (infolink) {
2070							title.appendChild(makeElement('a', {
2071								'class': 'ext_info',
2072								'target': '_blank',
2073								'href': infolink,
2074								'title': "More information"
2075							}, "Info"));
2076						}
2077						details.addEventListener("toggle", this._onDetailsToggle);
2078
2079						parent.appendChild(details);
2080
2081					}
2082				}
2083			},
2084
2085			_onDetailsToggle: function(e) {
2086				//console.info('BotMon.live.gui.lists._onDetailsToggle()');
2087
2088				const target = e.target;
2089
2090				if (target.getAttribute('data-loaded') == 'false') { // only if not loaded yet
2091					target.setAttribute('data-loaded', 'loading');
2092
2093					const fillType = target.getAttribute('data-group');
2094					const fillList = BotMon.live.data.analytics.groups[fillType];
2095					if (fillList && fillList.length > 0) {
2096
2097						const ul = BotMon.t._makeElement('ul');
2098
2099						fillList.forEach( (it) => {
2100							ul.appendChild(BotMon.live.gui.lists._makeVisitorItem(it, fillType));
2101						});
2102
2103						target.appendChild(ul);
2104						target.setAttribute('data-loaded', 'true');
2105					} else {
2106						target.setAttribute('data-loaded', 'false');
2107					}
2108
2109				}
2110			},
2111
2112			_makeVisitorItem: function(data, type) {
2113
2114				// shortcut for neater code:
2115				const make = BotMon.t._makeElement;
2116
2117				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
2118				if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
2119
2120				const platformName = (data._platform ? data._platform.n : 'Unknown');
2121				const clientName = (data._client ? data._client.n: 'Unknown');
2122
2123				const sumClass = ( !data._seenBy || data._seenBy.indexOf(BM_LOGTYPE.SERVER) < 0 ? 'noServer' : 'hasServer');
2124
2125				const li = make('li'); // root list item
2126				const details = make('details');
2127				const summary = make('summary', {
2128					'class': sumClass
2129				});
2130				details.appendChild(summary);
2131
2132				const span1 = make('span'); /* left-hand group */
2133
2134				if (data._type !== BM_USERTYPE.KNOWN_BOT) { /* No platform/client for bots */
2135					span1.appendChild(make('span', { /* Platform */
2136						'class': 'icon_only platform pf_' + (data._platform ? data._platform.id : 'unknown'),
2137						'title': "Platform: " + platformName
2138					}, platformName));
2139
2140					span1.appendChild(make('span', { /* Client */
2141						'class': 'icon_only client client cl_' + (data._client ? data._client.id : 'unknown'),
2142						'title': "Client: " + clientName
2143					}, clientName));
2144				}
2145
2146				// identifier:
2147				if (data._type == BM_USERTYPE.KNOWN_BOT) { /* Bot only */
2148
2149					const botName = ( data._bot && data._bot.n ? data._bot.n : "Unknown");
2150					span1.appendChild(make('span', { /* Bot */
2151						'class': 'has_icon bot bot_' + (data._bot ? data._bot.id : 'unknown'),
2152						'title': "Bot: " + botName
2153					}, botName));
2154
2155				} else if (data._type == BM_USERTYPE.KNOWN_USER) { /* User only */
2156
2157					span1.appendChild(make('span', { /* User */
2158						'class': 'has_icon user_known',
2159						'title': "User: " + data.usr
2160					}, data.usr));
2161
2162				} else { /* others */
2163
2164
2165					/*span1.appendChild(make('span', { // IP-Address
2166						'class': 'has_icon ipaddr ip' + ipType,
2167						'title': "IP-Address: " + data.ip
2168					}, data.ip));*/
2169
2170					span1.appendChild(make('span', { /* Internal ID */
2171						'class': 'has_icon session typ_' + data.typ,
2172						'title': "ID: " + data.id
2173					}, data.id));
2174				}
2175
2176				// country flag:
2177				if (data.geo && data.geo !== 'ZZ') {
2178					span1.appendChild(make('span', {
2179						'class': 'icon_only country ctry_' + data.geo.toLowerCase(),
2180						'data-ctry': data.geo,
2181						'title': "Country: " + ( data._country || "Unknown")
2182					}, ( data._country || "Unknown") ));
2183				}
2184
2185				// referer icons:
2186				if ((data._type == BM_USERTYPE.PROBABLY_HUMAN || data._type == BM_USERTYPE.LIKELY_BOT) && data.ref) {
2187					const refInfo = BotMon.live.data.analytics.getRefererInfo(data.ref);
2188					span1.appendChild(make('span', {
2189						'class': 'icon_only referer ref_' + refInfo.id,
2190						'title': "Referer: " + data.ref
2191					}, refInfo.n));
2192				}
2193
2194				summary.appendChild(span1);
2195				const span2 = make('span'); /* right-hand group */
2196
2197					span2.appendChild(make('span', { /* first-seen */
2198						'class': 'has_iconfirst-seen',
2199						'title': "First seen: " + data._firstSeen.toLocaleString() + " UTC"
2200					}, BotMon.t._formatTime(data._firstSeen)));
2201
2202					span2.appendChild(make('span', { /* page views */
2203						'class': 'has_icon pageviews',
2204						'title': data._pageViews.length + " page view(s)"
2205					}, data._pageViews.length));
2206
2207				summary.appendChild(span2);
2208
2209				// add details expandable section:
2210				details.appendChild(BotMon.live.gui.lists._makeVisitorDetails(data, type));
2211
2212				li.appendChild(details);
2213				return li;
2214			},
2215
2216			_makeVisitorDetails: function(data, type) {
2217
2218				// shortcut for neater code:
2219				const make = BotMon.t._makeElement;
2220
2221				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
2222				if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
2223				const platformName = (data._platform ? data._platform.n : 'Unknown');
2224				const clientName = (data._client ? data._client.n: 'Unknown');
2225
2226				const dl = make('dl', {'class': 'visitor_details'});
2227
2228				if (data._type == BM_USERTYPE.KNOWN_BOT) {
2229
2230					dl.appendChild(make('dt', {}, "Bot name:")); /* bot info */
2231					dl.appendChild(make('dd', {'class': 'icon_only bot bot_' + (data._bot ? data._bot.id : 'unknown')},
2232						(data._bot ? data._bot.n : 'Unknown')));
2233
2234					if (data._bot && data._bot.url) {
2235						dl.appendChild(make('dt', {}, "Bot info:")); /* bot info */
2236						const botInfoDd = dl.appendChild(make('dd'));
2237						botInfoDd.appendChild(make('a', {
2238							'href': data._bot.url,
2239							'target': '_blank'
2240						}, data._bot.url)); /* bot info link*/
2241
2242					}
2243
2244				} else { /* not for bots */
2245
2246					dl.appendChild(make('dt', {}, "Client:")); /* client */
2247					dl.appendChild(make('dd', {'class': 'has_icon client cl_' + (data._client ? data._client.id : 'unknown')},
2248						clientName + ( data._client.v > 0 ? ' (' + data._client.v + ')' : '' ) ));
2249
2250					dl.appendChild(make('dt', {}, "Platform:")); /* platform */
2251					dl.appendChild(make('dd', {'class': 'has_icon platform pf_' + (data._platform ? data._platform.id : 'unknown')},
2252						platformName + ( data._platform.v > 0 ? ' (' + data._platform.v + ')' : '' ) ));
2253
2254					/*dl.appendChild(make('dt', {}, "ID:"));
2255					dl.appendChild(make('dd', {'class': 'has_icon ip' + data.typ}, data.id));*/
2256				}
2257
2258				dl.appendChild(make('dt', {}, "IP-Address:"));
2259				const ipItem = make('dd', {'class': 'has_icon ipaddr ip' + ipType});
2260					ipItem.appendChild(make('span', {'class': 'address'} , data.ip));
2261					ipItem.appendChild(make('a', {
2262						'class': 'icon_only extlink ipinfo',
2263						'href': `https://ipinfo.io/${encodeURIComponent(data.ip)}`,
2264						'target': 'ipinfo',
2265						'title': "View this address on IPInfo.io"
2266					} , "DNS Info"));
2267					ipItem.appendChild(make('a', {
2268						'class': 'icon_only extlink abuseipdb',
2269						'href': `https://www.abuseipdb.com/check/${encodeURIComponent(data.ip)}`,
2270						'target': 'abuseipdb',
2271						'title': "Check this address on AbuseIPDB.com"
2272					} , "Check on AbuseIPDB"));
2273				dl.appendChild(ipItem);
2274
2275				if (Math.abs(data._lastSeen - data._firstSeen) < 100) {
2276					dl.appendChild(make('dt', {}, "Seen:"));
2277					dl.appendChild(make('dd', {'class': 'seen'}, data._firstSeen.toLocaleString()));
2278				} else {
2279					dl.appendChild(make('dt', {}, "First seen:"));
2280					dl.appendChild(make('dd', {'class': 'firstSeen'}, data._firstSeen.toLocaleString()));
2281					dl.appendChild(make('dt', {}, "Last seen:"));
2282					dl.appendChild(make('dd', {'class': 'lastSeen'}, data._lastSeen.toLocaleString()));
2283				}
2284
2285				dl.appendChild(make('dt', {}, "User-Agent:"));
2286				dl.appendChild(make('dd', {'class': 'agent'}, data.agent));
2287
2288				dl.appendChild(make('dt', {}, "Languages:"));
2289				dl.appendChild(make('dd', {'class': 'langs'}, ` [${data.accept}]`));
2290
2291				if (data.geo && data.geo !=='') {
2292					dl.appendChild(make('dt', {}, "Location:"));
2293					dl.appendChild(make('dd', {
2294						'class': 'has_icon country ctry_' + data.geo.toLowerCase(),
2295						'data-ctry': data.geo,
2296						'title': "Country: " + data._country
2297					}, data._country + ' (' + data.geo + ')'));
2298				}
2299
2300				dl.appendChild(make('dt', {}, "Session ID:"));
2301				dl.appendChild(make('dd', {'class': 'has_icon session typ_' + data.typ}, data.id));
2302
2303				dl.appendChild(make('dt', {}, "Seen by:"));
2304				dl.appendChild(make('dd', undefined, data._seenBy.join(', ') ));
2305
2306				dl.appendChild(make('dt', {}, "Visited pages:"));
2307				const pagesDd = make('dd', {'class': 'pages'});
2308				const pageList = make('ul');
2309
2310				/* list all page views */
2311				data._pageViews.sort( (a, b) => a._firstSeen - b._firstSeen );
2312				data._pageViews.forEach( (page) => {
2313					pageList.appendChild(BotMon.live.gui.lists._makePageViewItem(page));
2314				});
2315				pagesDd.appendChild(pageList);
2316				dl.appendChild(pagesDd);
2317
2318				/* bot evaluation rating */
2319				if (data._type !== BM_USERTYPE.KNOWN_BOT && data._type !== BM_USERTYPE.KNOWN_USER) {
2320					dl.appendChild(make('dt', undefined, "Bot rating:"));
2321					dl.appendChild(make('dd', {'class': 'bot-rating'}, ( data._botVal ? data._botVal : '–' ) + ' (of ' + BotMon.live.data.rules._threshold + ')'));
2322
2323					/* add bot evaluation details: */
2324					if (data._eval) {
2325						dl.appendChild(make('dt', {}, "Bot evaluation:"));
2326						const evalDd = make('dd', {'class': 'eval'});
2327						const testList = make('ul');
2328						data._eval.forEach( test => {
2329
2330							const tObj = BotMon.live.data.rules.getRuleInfo(test);
2331							let tDesc = tObj ? tObj.desc : test;
2332
2333							// special case for Bot IP range test:
2334							if (tObj.func == 'fromKnownBotIP') {
2335								const rangeInfo = BotMon.live.data.ipRanges.match(data.ip);
2336								if (rangeInfo) {
2337									const owner = BotMon.live.data.ipRanges.getOwner(rangeInfo.g);
2338									tDesc += ' (range: “' + rangeInfo.cidr + '”, ' + owner + ')';
2339								}
2340							}
2341
2342							// create the entry field
2343							const tstLi = make('li');
2344							tstLi.appendChild(make('span', {
2345								'data-testid': test
2346							}, tDesc));
2347							tstLi.appendChild(make('span', {}, ( tObj ? tObj.bot : '—') ));
2348							testList.appendChild(tstLi);
2349						});
2350
2351						// add total row
2352						const tst2Li = make('li', {
2353							'class': 'total'
2354						});
2355						/*tst2Li.appendChild(make('span', {}, "Total:"));
2356						tst2Li.appendChild(make('span', {}, data._botVal));
2357						testList.appendChild(tst2Li);*/
2358
2359						evalDd.appendChild(testList);
2360						dl.appendChild(evalDd);
2361					}
2362				}
2363				// return the element to add to the UI:
2364				return dl;
2365			},
2366
2367			// make a page view item:
2368			_makePageViewItem: function(page) {
2369				//console.log("makePageViewItem:",page);
2370
2371				// shortcut for neater code:
2372				const make = BotMon.t._makeElement;
2373
2374				// the actual list item:
2375				const pgLi = make('li');
2376
2377				const row1 = make('div', {'class': 'row'});
2378
2379					row1.appendChild(make('a', { // page id is the left group
2380						'href': DOKU_BASE + 'doku.php?id=' + encodeURIComponent(page.pg),
2381						'target': 'preview',
2382						'hreflang': page.lang,
2383						'title': "PageID: " + page.pg
2384					}, page.pg)); /* DW Page ID */
2385
2386					// get the time difference:
2387					row1.appendChild(make('span', {
2388						'class': 'first-seen',
2389						'title': "First visited: " + page._firstSeen.toLocaleString() + " UTC"
2390					}, BotMon.t._formatTime(page._firstSeen)));
2391
2392				pgLi.appendChild(row1);
2393
2394				/* LINE 2 */
2395
2396				const row2 = make('div', {'class': 'row'});
2397
2398					// page referrer:
2399					if (page._ref) {
2400						row2.appendChild(make('span', {
2401							'class': 'referer',
2402							'title': "Referrer: " + page._ref.href
2403						}, page._ref.hostname));
2404					} else {
2405						row2.appendChild(make('span', {
2406							'class': 'referer'
2407						}, "No referer"));
2408					}
2409
2410					// visit duration:
2411					let visitTimeStr = "Bounce";
2412					const visitDuration = page._lastSeen.getTime() - page._firstSeen.getTime();
2413					if (visitDuration > 0) {
2414						visitTimeStr = Math.floor(visitDuration / 1000) + "s";
2415					}
2416					const tDiff = BotMon.t._formatTimeDiff(page._firstSeen, page._lastSeen);
2417					if (tDiff) {
2418						row2.appendChild(make('span', {'class': 'visit-length', 'title': 'Last seen: ' + page._lastSeen.toLocaleString()}, tDiff));
2419					} else {
2420						row2.appendChild(make('span', {
2421							'class': 'bounce',
2422							'title': "Visitor bounced"}, "Bounce"));
2423					}
2424
2425				pgLi.appendChild(row2);
2426
2427				return pgLi;
2428			}
2429		}
2430	}
2431};
2432
2433/* launch only if the BotMon admin panel is open: */
2434if (document.getElementById('botmon__admin')) {
2435	BotMon.init();
2436}