xref: /plugin/botmon/admin.js (revision cb91697912a3c2642b72c3d0bc9fa6758b1e5bb4) !
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.lastIndexOf('/')+1);
36
37		// read the page language from the DOM:
38		this._lang = document.getRootNode().documentElement.lang || this._lang;
39
40		// get the time offset:
41		this._timeDiff = BotMon.t._getTimeOffset();
42
43		// get yesterday's date:
44		let d = new Date();
45		if (BMSettings.showday == 'yesterday') d.setDate(d.getDate() - 1);
46		this._datestr = d.toISOString().slice(0, 10);
47
48		// init the sub-objects:
49		BotMon.t._callInit(this);
50	},
51
52	_baseDir: null,
53	_lang: 'en',
54	_datestr: '',
55	_timeDiff: '',
56
57	/* internal tools */
58	t: {
59		/* helper function to call inits of sub-objects */
60		_callInit: function(obj) {
61			//console.info('BotMon.t._callInit(obj=',obj,')');
62
63			/* call init / _init on each sub-object: */
64			Object.keys(obj).forEach( (key,i) => {
65				const sub = obj[key];
66				let init = null;
67				if (typeof sub === 'object' && sub.init) {
68					init = sub.init;
69				}
70
71				// bind to object
72				if (typeof init == 'function') {
73					const init2 = init.bind(sub);
74					init2(obj);
75				}
76			});
77		},
78
79		/* helper function to calculate the time difference to UTC: */
80		_getTimeOffset: function() {
81			const now = new Date();
82			let offset = now.getTimezoneOffset(); // in minutes
83			const sign = Math.sign(offset); // +1 or -1
84			offset = Math.abs(offset); // always positive
85
86			let hours = 0;
87			while (offset >= 60) {
88				hours += 1;
89				offset -= 60;
90			}
91			return ( hours > 0 ? sign * hours + ' h' : '') + (offset > 0 ? ` ${offset} min` : '');
92		},
93
94		/* helper function to create a new element with all attributes and text content */
95		_makeElement: function(name, atlist = undefined, text = undefined) {
96			var r = null;
97			try {
98				r = document.createElement(name);
99				if (atlist) {
100					for (let attr in atlist) {
101						r.setAttribute(attr, atlist[attr]);
102					}
103				}
104				if (text) {
105					r.textContent = text.toString();
106				}
107			} catch(e) {
108				console.error(e);
109			}
110			return r;
111		},
112
113		/* helper to convert an ip address string to a normalised format: */
114		_ip2Num: function(ip) {
115			if (!ip) {
116				return 'null';
117			} else if (ip.indexOf(':') > 0) { /* IP6 */
118				return (ip.split(':').map(d => ('0000'+d).slice(-4) ).join(':'));
119			} else { /* IP4 */
120				return ip.split('.').map(d => ('000'+d).slice(-3) ).join('.');
121			}
122		},
123
124		/* helper function to format a Date object to show only the time. */
125		/* returns String */
126		_formatTime: function(date) {
127
128			if (date) {
129				return date.getHours() + ':' + ('0'+date.getMinutes()).slice(-2) + ':' + ('0'+date.getSeconds()).slice(-2);
130			} else {
131				return null;
132			}
133
134		},
135
136		/* helper function to show a time difference in seconds or minutes */
137		/* returns String */
138		_formatTimeDiff: function(dateA, dateB) {
139
140			// if the second date is ealier, swap them:
141			if (dateA > dateB) dateB = [dateA, dateA = dateB][0];
142
143			// get the difference in milliseconds:
144			let ms = dateB - dateA;
145
146			if (ms > 50) { /* ignore small time spans */
147				const h = Math.floor((ms / (1000 * 60 * 60)) % 24);
148				const m = Math.floor((ms / (1000 * 60)) % 60);
149				const s = Math.floor((ms / 1000) % 60);
150
151				return ( h>0 ? h + 'h ': '') + ( m>0 ? m + 'm ': '') + ( s>0 ? s + 's': '');
152			}
153
154			return null;
155
156		},
157
158		// calcualte a reduced ration between two numbers
159		// adapted from https://stackoverflow.com/questions/3946373/math-in-js-how-do-i-get-a-ratio-from-a-percentage
160		_getRatio: function(a, b, tolerance) {
161
162			var bg = b;
163			var sm = a;
164			if (a == 0 || b == 0) return '—';
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				// combine Bot networks to one visitor?
296				const combineNets = (BMSettings.hasOwnProperty('combineNets') ? BMSettings['combineNets'] : true);;
297
298				if (visitor._type == BM_USERTYPE.KNOWN_BOT) { // known bots match by their bot ID:
299
300					for (let i=0; i<model._visitors.length; i++) {
301						const v = model._visitors[i];
302
303						// bots match when their ID matches:
304						if (v._bot && v._bot.id == visitor._bot.id) {
305							return v;
306						}
307					}
308
309				} else if (combineNets && visitor.hasOwnProperty('_ipRange')) { // combine with other visits from the same range
310
311					let nonRangeVisitor = null;
312
313					for (let i=0; i<model._visitors.length; i++) {
314						const v = model._visitors[i];
315
316						if ( v.hasOwnProperty('_ipRange') && v._ipRange.g == visitor._ipRange.g ) { // match the IPRange Group IDs
317							return v;
318						} else if ( v.id.trim() !== '' && v.id == visitor.id) { // match the DW/PHP IDs
319							nonRangeVisitor = v;
320						}
321					}
322
323					// if no ip range was found, return the non-range visitor instead
324					if (nonRangeVisitor) return nonRangeVisitor;
325
326				} else { // other types match by their DW/PHPIDs:
327
328					// loop over all visitors already registered and check for ID matches:
329					for (let i=0; i<model._visitors.length; i++) {
330						const v = model._visitors[i];
331
332						if ( v.id.trim() !== '' && v.id == visitor.id) { // match the DW/PHP IDs
333							return v;
334						}
335					}
336
337					// if not found, try to match IP address and user agent:
338					for (let i=0; i<model._visitors.length; i++) {
339						const v = model._visitors[i];
340						if (  v.ip == visitor.ip && v.agent == visitor.agent) {
341							return v;
342						}
343					}
344				}
345
346				return null; // nothing found
347			},
348
349			/* if there is already this visit registered, return the page view item */
350			_getPageView: function(visit, view) {
351
352				// shortcut to make code more readable:
353				const model = BotMon.live.data.model;
354
355				for (let i=0; i<visit._pageViews.length; i++) {
356					const pv = visit._pageViews[i];
357					if (pv.pg == view.pg) {
358						return pv;
359					}
360				}
361				return null; // not found
362			},
363
364			// register a new visitor (or update if already exists)
365			registerVisit: function(nv, type) {
366				//console.info('registerVisit', nv, type);
367
368
369				// shortcut to make code more readable:
370				const model = BotMon.live.data.model;
371
372				// is it a known bot?
373				const bot = BotMon.live.data.bots.match(nv.agent);
374
375				// enrich new visitor with relevant data:
376				if (!nv._bot) nv._bot = bot ?? null; // bot info
377				nv._type = ( bot ? BM_USERTYPE.KNOWN_BOT : ( nv.usr && nv.usr !== '' ? BM_USERTYPE.KNOWN_USER : BM_USERTYPE.UNKNOWN ) ); // user type
378				if (bot && bot.geo) {
379					if (!nv.geo || nv.geo == '' || nv.geo == 'ZZ') nv.geo = bot.geo;
380				} else if (!nv.geo ||nv.geo == '') {
381					nv.geo = 'ZZ';
382				}
383
384				// update first and last seen:
385				if (!nv._firstSeen) nv._firstSeen = nv.ts; // first-seen
386				nv._lastSeen = nv.ts; // last-seen
387
388				// known bot IP range?
389				if (nv._type == BM_USERTYPE.UNKNOWN) { // only for unknown visitors
390					const ipInfo = BotMon.live.data.ipRanges.match(nv.ip);
391					if (ipInfo) nv._ipRange = ipInfo;
392				}
393
394				// country name:
395				try {
396					nv._country = ( nv.geo == 'local' ? "localhost" : "Unknown" );
397					if (nv.geo && nv.geo !== '' && nv.geo !== 'ZZ' && nv.geo !== 'local') {
398						const countryName = new Intl.DisplayNames(['en', BotMon._lang], {type: 'region'});
399						nv._country = countryName.of(nv.geo.substring(0,2)) ?? nv.geo;
400					}
401				} catch (err) {
402					console.error(err);
403					nv._country = 'Error';
404				}
405
406				// check if it already exists:
407				let visitor = model.findVisitor(nv, type);
408				if (!visitor) {
409					visitor = {...nv, ...{
410						_seenBy: [type],
411						_viewCount: 0, // number of page views
412						_loadCount: 0, // number of page loads (not necessarily views!)
413						_pageViews: [], // array of page views
414						_hasReferrer: false, // has at least one referrer
415						_jsClient: false, // visitor has been seen logged by client js as well
416						_client: BotMon.live.data.clients.match(nv.agent) ?? null, // client info
417						_platform: BotMon.live.data.platforms.match(nv.agent), // platform info
418						_captcha: {'X': 0, 'Y': 0, 'N': 0, 'W':0, 'H': 0,
419							_str: function() { return (this.X > 0 ? 'X' : '') + (this.Y > 0 ? (this.Y > 1 ? 'YY' : 'Y') : '') + (this.N > 0 ? 'N' : '') + (this.W > 0 ? 'W' : '') + (this.H > 0 ? 'H' : ''); }
420						} // captcha counter
421					}};
422					model._visitors.push(visitor);
423				};
424
425				// update first and last seen:
426				if (visitor._firstSeen > nv.ts) {
427					visitor._firstSeen = nv.ts;
428				}
429				if (visitor._lastSeen < nv.ts) {
430					visitor._lastSeen = nv.ts;
431				}
432
433				// update total loads and views (not the same!):
434				visitor._loadCount += 1;
435				visitor._viewCount += (nv.captcha == 'Y' ? 0 : 1);
436
437				// ...because also a captcha is a "load", but not a "view".
438				// let's count the captcha statuses as well:
439				if (nv.captcha) visitor._captcha[nv.captcha] += 1;
440
441				// is this visit already registered?
442				let prereg = model._getPageView(visitor, nv);
443				if (!prereg) {
444					// add new page view:
445					prereg = model._makePageView(nv, type);
446					visitor._pageViews.push(prereg);
447				}
448
449				// update last seen date
450				prereg._lastSeen = nv.ts;
451
452				// increase view count:
453				prereg._loadCount += (visitor.captcha == 'Y' ? 0 : 1);
454				//prereg._tickCount += 1;
455
456				// update referrer state:
457				visitor._hasReferrer = visitor._hasReferrer ||
458					(prereg.ref !== undefined && prereg.ref !== '');
459
460				// update time stamp for last-seen:
461				if (visitor._lastSeen < nv.ts) {
462					visitor._lastSeen = nv.ts;
463				}
464
465				// if needed:
466				return visitor;
467			},
468
469			// updating visit data from the client-side log:
470			updateVisit: function(dat) {
471				//console.info('updateVisit', dat);
472
473				// shortcut to make code more readable:
474				const model = BotMon.live.data.model;
475
476				const type = BM_LOGTYPE.CLIENT;
477
478				let visitor = BotMon.live.data.model.findVisitor(dat, type);
479				if (!visitor) {
480					visitor = model.registerVisit(dat, type);
481				}
482				if (visitor) {
483
484					if (visitor._lastSeen < dat.ts) {
485						visitor._lastSeen = dat.ts;
486					}
487					if (!visitor._seenBy.includes(type)) {
488						visitor._seenBy.push(type);
489					}
490					visitor._jsClient = true; // seen by client js
491				}
492
493				// find the page view:
494				let prereg = BotMon.live.data.model._getPageView(visitor, dat);
495				if (prereg) {
496					// update the page view:
497					prereg._lastSeen = dat.ts;
498					if (!prereg._seenBy.includes(type)) prereg._seenBy.push(type);
499					prereg._jsClient = true; // seen by client js
500				} else {
501					// add the page view to the visitor:
502					prereg = model._makePageView(dat, type);
503					visitor._pageViews.push(prereg);
504				}
505				prereg._tickCount += 1;
506			},
507
508			// updating visit data from the ticker log:
509			updateTicks: function(dat) {
510				//console.info('updateTicks', dat);
511
512				// shortcut to make code more readable:
513				const model = BotMon.live.data.model;
514
515				const type = BM_LOGTYPE.TICKER;
516
517				// find the visit info:
518				let visitor = model.findVisitor(dat, type);
519				if (!visitor) {
520					console.info(`No visitor with ID “${dat.id}” found, registering as a new one.`);
521					visitor = model.registerVisit(dat, type, true);
522				}
523				if (visitor) {
524					// update visitor:
525					if (visitor._lastSeen < dat.ts) visitor._lastSeen = dat.ts;
526					if (!visitor._seenBy.includes(type)) visitor._seenBy.push(type);
527
528					// get the page view info:
529					let pv = model._getPageView(visitor, dat);
530					if (!pv) {
531						console.info(`No page view for visit ID “${dat.id}”, page “${dat.pg}”, registering a new one.`);
532						pv = model._makePageView(dat, type);
533						visitor._pageViews.push(pv);
534					}
535
536					// update the page view info:
537					if (!pv._seenBy.includes(type)) pv._seenBy.push(type);
538					if (pv._lastSeen.getTime() < dat.ts.getTime()) pv._lastSeen = dat.ts;
539					pv._tickCount += 1;
540
541				}
542			},
543
544			// helper function to create a new "page view" item:
545			_makePageView: function(data, type) {
546				// console.info('_makePageView', data);
547
548				// try to parse the referrer:
549				let rUrl = null;
550				try {
551					rUrl = ( data.ref && data.ref !== '' ? new URL(data.ref) : null );
552				} catch (e) {
553					console.warn(`Invalid referer: “${data.ref}”.`);
554					console.info(data);
555				}
556
557				return {
558					_by: type,
559					ip: data.ip,
560					pg: data.pg,
561					lang: data.lang || '??',
562					_ref: rUrl,
563					_firstSeen: data.ts,
564					_lastSeen: data.ts,
565					_seenBy: [type],
566					_jsClient: ( type !== BM_LOGTYPE.SERVER),
567					_viewCount: 0,
568					_loadCount: 0,
569					_tickCount: 0
570				};
571			},
572
573			// helper function to make a human-readable title from the Captcha statuses:
574			_makeCaptchaTitle: function(cObj) {
575				const cStr = cObj._str();
576				switch (cStr) {
577					case 'Y': return "Blocked.";
578					case 'YY': return "Blocked multiple times.";
579					case 'YN': return "Solved";
580					case 'YYN': return "Solved after multiple attempts";
581					case 'W': return "Whitelisted";
582					case 'H': return "HEAD request, no captcha";
583					case 'YH': case 'YYH': return "Block & HEAD mixed";
584					default: return "Undefined: " + cStr;
585				}
586			}
587		},
588
589		// functions to analyse the data:
590		analytics: {
591
592			/**
593			 * Initializes the analytics data storage object:
594			 */
595			init: function() {
596				//console.info('BotMon.live.data.analytics.init()');
597			},
598
599			// data storage:
600			data: {
601				visits: {
602					bots: 0,
603					suspected: 0,
604					humans: 0,
605					users: 0,
606					total: 0
607				},
608				views: {
609					bots: 0,
610					suspected: 0,
611					humans: 0,
612					users: 0,
613					total: 0
614				},
615				loads: {
616					bots: 0,
617					suspected: 0,
618					humans: 0,
619					users: 0,
620					total: 0
621				},
622				captcha: {
623					bots_blocked: 0,
624					bots_passed: 0,
625					bots_whitelisted: 0,
626					humans_blocked: 0,
627					humans_passed: 0,
628					humans_whitelisted: 0,
629					sus_blocked: 0,
630					sus_passed: 0,
631					sus_whitelisted: 0
632				}
633			},
634
635			// sort the visits by type:
636			groups: {
637				knownBots: [],
638				suspectedBots: [],
639				humans: [],
640				users: []
641			},
642
643			// all analytics
644			analyseAll: function() {
645				//console.info('BotMon.live.data.analytics.analyseAll()');
646
647				// shortcut to make code more readable:
648				const model = BotMon.live.data.model;
649				const data = BotMon.live.data.analytics.data;
650				const me = BotMon.live.data.analytics;
651
652				BotMon.live.gui.status.showBusy("Analysing data …");
653
654				// loop over all visitors:
655				model._visitors.forEach( (v) => {
656
657					const captchaStr = v._captcha._str().replaceAll(/[^YNW]/g, '');
658
659					// count total visits and page views:
660					data.visits.total += 1;
661					data.loads.total += v._loadCount;
662					data.views.total += v._viewCount;
663
664					// check for typical bot aspects:
665					let botScore = 0;
666
667					if (v._type == BM_USERTYPE.KNOWN_BOT) { // known bots
668
669						this.groups.knownBots.push(v);
670
671						if (v._seenBy.indexOf(BM_LOGTYPE.SERVER) > -1) { // not for ghost items!
672							data.visits.bots += 1;
673							data.views.bots += v._viewCount;
674
675							// captcha counter
676							if (captchaStr.indexOf('YN') > -1) {
677								data.captcha.bots_passed += 1;
678							} else if (captchaStr.indexOf('Y') > -1) {
679								data.captcha.bots_blocked += 1;
680							}
681							if (captchaStr.indexOf('W') > -1) {
682								data.captcha.bots_whitelisted += 1;
683							}
684						}
685
686					} else if (v._type == BM_USERTYPE.KNOWN_USER) { // known users */
687
688						data.visits.users += 1;
689						data.views.users += v._viewCount;
690						this.groups.users.push(v);
691
692					} else {
693
694						// get evaluation:
695						const e = BotMon.live.data.rules.evaluate(v);
696						v._eval = e.rules;
697						v._botVal = e.val;
698
699						if (e.isBot) { // likely bots
700
701							v._type = BM_USERTYPE.LIKELY_BOT;
702							this.groups.suspectedBots.push(v);
703
704							if (v._seenBy.indexOf(BM_LOGTYPE.SERVER) > -1) { // not for ghost items!
705
706								data.visits.suspected += 1;
707								data.views.suspected += v._viewCount;
708
709								// captcha counter
710								if (captchaStr.indexOf('YN') > -1) {
711									data.captcha.sus_passed += 1;
712								} else if (captchaStr.indexOf('Y') > -1) {
713									data.captcha.sus_blocked += 1;
714								}
715								if (captchaStr.indexOf('W') > -1) {
716									data.captcha.sus_whitelisted += 1;
717								}
718							}
719
720						} else { // probably humans
721
722							v._type = BM_USERTYPE.PROBABLY_HUMAN;
723							this.groups.humans.push(v);
724
725							if (v._seenBy.indexOf(BM_LOGTYPE.SERVER) > -1) { // not for ghost items!
726								data.visits.humans += 1;
727								data.views.humans += v._viewCount;
728
729								// captcha counter
730								if (captchaStr.indexOf('YN') > -1) {
731									data.captcha.humans_passed += 1;
732								} else if (captchaStr.indexOf('Y') > -1) {
733									data.captcha.humans_blocked += 1;
734								}
735								if (captchaStr.indexOf('W') > -1) {
736									data.captcha.humans_whitelisted += 1;
737								}
738							}
739						}
740					}
741
742					// perform actions depending on the visitor type:
743					if (v._type == BM_USERTYPE.KNOWN_BOT ) { /* known bots only */
744
745						// no specific actions here.
746
747					} else if (v._type == BM_USERTYPE.LIKELY_BOT) { /* probable bots only */
748
749						// add bot views to IP range information:
750						if (v.ip) {
751							me.addToIpRanges(v);
752						} else {
753							console.log(v);
754						}
755
756					} else { /* registered users and probable humans */
757
758						// add browser and platform statistics:
759						me.addBrowserPlatform(v);
760
761						// add to referrer and pages lists:
762						v._pageViews.forEach( pv => {
763							me.addToRefererList(pv._ref);
764							me.addToPagesList(pv.pg);
765						});
766					}
767
768					// add to the country lists:
769					me.addToCountries(v.geo, v._country, v._type);
770
771				});
772
773				BotMon.live.gui.status.hideBusy('Done.');
774			},
775
776			// get a list of known bots:
777			getTopBots: function(max) {
778				//console.info('BotMon.live.data.analytics.getTopBots('+max+')');
779
780				//console.log(BotMon.live.data.analytics.groups.knownBots);
781
782				let botsList = BotMon.live.data.analytics.groups.knownBots.toSorted( (a, b) => {
783					return b._viewCount - a._viewCount;
784				});
785
786				const other = {
787					'id': 'other',
788					'name': "Others",
789					'count': 0
790				};
791
792				const rList = [];
793				const max2 = ( botsList.length > max ? max-1 : botsList.length );
794				let total = 0; // adding up the items
795				for (let i=0; i<botsList.length; i++) {
796					const it = botsList[i];
797					if (it && it._bot) {
798						if (i < max2) {
799							rList.push({
800								id: it._bot.id,
801								name: (it._bot.n ? it._bot.n : it._bot.id),
802								count: it._viewCount
803							});
804						} else {
805							other.count += it._pageViews.length;
806						};
807						total += it._viewCount;
808					}
809				};
810
811				// add the "other" item, if needed:
812				if (botsList.length > max2) {
813					rList.push(other);
814				};
815
816				rList.forEach( it => {
817					it.pct = (it.count * 100 / total);
818				});
819
820				return rList;
821			},
822
823			// most visited pages list:
824			_pagesList: [],
825
826			/**
827			 * Add a page view to the list of most visited pages.
828			 * @param {string} pageId - The page ID to add to the list.
829			 * @example
830			 * BotMon.live.data.analytics.addToPagesList('1234567890');
831			 */
832			addToPagesList: function(pageId) {
833				//console.log('BotMon.live.data.analytics.addToPagesList', pageId);
834
835				const me = BotMon.live.data.analytics;
836
837				// already exists?
838				let pgObj = null;
839				for (let i = 0; i < me._pagesList.length; i++) {
840					if (me._pagesList[i].id == pageId) {
841						pgObj = me._pagesList[i];
842						break;
843					}
844				}
845
846				// if not exists, create it:
847				if (!pgObj) {
848					pgObj = {
849						id: pageId,
850						count: 1
851					};
852					me._pagesList.push(pgObj);
853				} else {
854					pgObj.count += 1;
855				}
856			},
857
858			getTopPages: function(max) {
859				//console.info('BotMon.live.data.analytics.getTopPages('+max+')');
860				const me = BotMon.live.data.analytics;
861				return me._pagesList.toSorted( (a, b) => {
862					return b.count - a.count;
863				}).slice(0,max);
864			},
865
866			// Referer List:
867			_refererList: [],
868
869			addToRefererList: function(ref) {
870				//console.log('BotMon.live.data.analytics.addToRefererList',ref);
871
872				const me = BotMon.live.data.analytics;
873
874				// ignore internal references:
875				if (ref && ref.host == window.location.host) {
876					return;
877				}
878
879				const refInfo = me.getRefererInfo(ref);
880
881				// already exists?
882				let refObj = null;
883				for (let i = 0; i < me._refererList.length; i++) {
884					if (me._refererList[i].id == refInfo.id) {
885						refObj = me._refererList[i];
886						break;
887					}
888				}
889
890				// if not exists, create it:
891				if (!refObj) {
892					refObj = refInfo;
893					refObj.count = 1;
894					me._refererList.push(refObj);
895				} else {
896					refObj.count += 1;
897				}
898			},
899
900			getRefererInfo: function(url) {
901				//console.log('BotMon.live.data.analytics.getRefererInfo',url);
902				try {
903					url = new URL(url);
904				} catch (e) {
905					return {
906						'id': 'null',
907						'n': 'Invalid Referer'
908					};
909				}
910
911				// find the referer ID:
912				let refId = 'null';
913				let refName = 'No Referer';
914				if (url && url.host) {
915					const hArr = url.host.split('.');
916					const tld = hArr[hArr.length-1];
917					refId = ( tld == 'localhost' ? tld : hArr[hArr.length-2]);
918					refName = hArr[hArr.length-2] + '.' + tld;
919				}
920
921				return {
922					'id': refId,
923					'n': refName
924				};
925			},
926
927			/**
928			 * Get a sorted list of the top referers.
929			 * The list is sorted in descending order of count.
930			 * If the array has more items than the given maximum, the rest of the items are added to an "other" item.
931			 * Each item in the list has a "pct" property, which is the percentage of the total count.
932			 * @param {number} max - The maximum number of items to return.
933			 * @return {Array} The sorted list of top referers.
934			 */
935			getTopReferers: function(max) {
936				//console.info(('BotMon.live.data.analytics.getTopReferers(' + max + ')'));
937
938				const me = BotMon.live.data.analytics;
939
940				return me._makeTopList(me._refererList, max);
941			},
942
943			/**
944			 * Create a sorted list of top items from a given array.
945			 * The list is sorted in descending order of count.
946			 * If the array has more items than the given maximum, the rest of the items are added to an "other" item.
947			 * Each item in the list has a "pct" property, which is the percentage of the total count.
948			 * @param {Array} arr - The array to sort and truncate.
949			 * @param {number} max - The maximum number of items to return.
950			 * @return {Array} The sorted list of top items.
951			 */
952			_makeTopList: function(arr, max) {
953				//console.info(('BotMon.live.data.analytics._makeTopList(arr,' + max + ')'));
954
955				const me = BotMon.live.data.analytics;
956
957				// sort the list:
958				arr.sort( (a,b) => {
959					return b.count - a.count;
960				});
961
962				const rList = []; // return array
963				const max2 = ( arr.length >= max ? max-1 : arr.length );
964				const other = {
965					'id': 'other',
966					'name': "Others",
967					'typ': 'other',
968					'count': 0
969				};
970				let total = 0; // adding up the items
971				for (let i=0; Math.min(max, arr.length) > i; i++) {
972					const it = arr[i];
973					if (it) {
974						if (i < max2) {
975							const rIt = {
976								id: it.id,
977								name: (it.n ? it.n : it.id),
978								typ: it.typ || it.id,
979								count: it.count
980							};
981							rList.push(rIt);
982						} else {
983							other.count += it.count;
984						}
985						total += it.count;
986					}
987				}
988
989				// add the "other" item, if needed:
990				if (arr.length > max2) {
991					rList.push(other);
992				};
993
994				rList.forEach( it => {
995					it.pct = (it.count * 100 / total);
996				});
997
998				return rList;
999			},
1000
1001			/* countries of visits */
1002			_countries: {
1003				'human': [],
1004				'bot': []
1005			},
1006			/**
1007			 * Adds a country code to the statistics.
1008			 *
1009			 * @param {string} iso The ISO 3166-1 alpha-2 country code.
1010			 */
1011			addToCountries: function(iso, name, type) {
1012
1013				const me = BotMon.live.data.analytics;
1014
1015				// find the correct array:
1016				let arr = null;
1017				switch (type) {
1018
1019					case BM_USERTYPE.KNOWN_USER:
1020					case BM_USERTYPE.PROBABLY_HUMAN:
1021						arr = me._countries.human;
1022						break;
1023					case BM_USERTYPE.LIKELY_BOT:
1024					case BM_USERTYPE.KNOWN_BOT:
1025						arr = me._countries.bot;
1026						break;
1027					default:
1028						console.warn(`Unknown user type ${type} in function addToCountries.`);
1029				}
1030
1031				if (arr) {
1032					let cRec = arr.find( it => it.id == iso);
1033					if (!cRec) {
1034						cRec = {
1035							'id': iso,
1036							'n': name,
1037							'count': 1
1038						};
1039						arr.push(cRec);
1040					} else {
1041						cRec.count += 1;
1042					}
1043				}
1044			},
1045
1046			/**
1047			 * Returns a list of countries with visit counts, sorted by visit count in descending order.
1048			 *
1049			 * @param {BM_USERTYPE} type array of types type of visitors to return.
1050			 * @param {number} max The maximum number of entries to return.
1051			 * @return {Array} A list of objects with properties 'iso' (ISO 3166-1 alpha-2 country code) and 'count' (visit count).
1052			 */
1053			getCountryList: function(type, max) {
1054
1055				const me = BotMon.live.data.analytics;
1056
1057				// find the correct array:
1058				let arr = null;
1059				switch (type) {
1060
1061					case 'human':
1062						arr = me._countries.human;
1063						break;
1064					case 'bot':
1065						arr = me._countries.bot;
1066						break;
1067					default:
1068						console.warn(`Unknown user type ${type} in function getCountryList.`);
1069						return;
1070				}
1071
1072				return me._makeTopList(arr, max);
1073			},
1074
1075			/* browser and platform of human visitors */
1076			_browsers: [],
1077			_platforms: [],
1078
1079			addBrowserPlatform: function(visitor) {
1080				//console.info('addBrowserPlatform', visitor);
1081
1082				const me = BotMon.live.data.analytics;
1083
1084				// add to browsers list:
1085				let browserRec = ( visitor._client ? visitor._client : {'id': 'unknown'});
1086				if (visitor._client) {
1087					let bRec = me._browsers.find( it => it.id == browserRec.id);
1088					if (!bRec) {
1089						bRec = {
1090							id: browserRec.id,
1091							n: browserRec.n,
1092							count: 1
1093						};
1094						me._browsers.push(bRec);
1095					} else {
1096						bRec.count += 1;
1097					}
1098				}
1099
1100				// add to platforms list:
1101				let platformRec = ( visitor._platform ? visitor._platform : {'id': 'unknown'});
1102				if (visitor._platform) {
1103					let pRec = me._platforms.find( it => it.id == platformRec.id);
1104					if (!pRec) {
1105						pRec = {
1106							id: platformRec.id,
1107							n: platformRec.n,
1108							count: 1
1109						};
1110						me._platforms.push(pRec);
1111					} else {
1112						pRec.count += 1;
1113					}
1114				}
1115
1116			},
1117
1118			getTopBrowsers: function(max) {
1119
1120				const me = BotMon.live.data.analytics;
1121
1122				return me._makeTopList(me._browsers, max);
1123			},
1124
1125			getTopPlatforms: function(max) {
1126
1127				const me = BotMon.live.data.analytics;
1128
1129				return me._makeTopList(me._platforms, max);
1130			},
1131
1132			/* bounces are counted, not calculates: */
1133			getBounceCount: function(type) {
1134
1135				const me = BotMon.live.data.analytics;
1136				var bounces = 0;
1137				const list = me.groups[type];
1138
1139				list.forEach(it => {
1140					bounces += (it._viewCount <= 1 ? 1 : 0);
1141				});
1142
1143				return bounces;
1144			},
1145
1146			_ipRanges: [],
1147
1148			/* adds a visit to the ip ranges arrays */
1149			addToIpRanges: function(v) {
1150				//console.info('addToIpRanges', v.ip);
1151
1152				const me = BotMon.live.data.analytics;
1153				const ipRanges = BotMon.live.data.ipRanges;
1154
1155				// Number of IP address segments to look at:
1156				const kIP4Segments = 1;
1157				const kIP6Segments = 3;
1158
1159				let ipGroup = ''; // group name
1160				let ipSeg = []; // IP segment array
1161				let rawIP = ''; // raw ip prefix
1162				let ipName = ''; // IP group display name
1163
1164				const ipType = v.ip.indexOf(':') > 0 ? BM_IPVERSION.IPv6 : BM_IPVERSION.IPv4;
1165				const ipAddr = BotMon.t._ip2Num(v.ip);
1166
1167				// is there already a known IP range assigned?
1168				if (v._ipRange) {
1169
1170					ipGroup = v._ipRange.g; // group name
1171					ipName = ipRanges.getOwner( v._ipRange.g ) || "Unknown";
1172
1173				} else { // no known IP range, let's collect necessary information:
1174
1175					// collect basic IP address info:
1176					if (ipType == BM_IPVERSION.IPv6) {
1177						ipSeg = ipAddr.split(':');
1178						const prefix = v.ip.split(':').slice(0, kIP6Segments).join(':');
1179						rawIP = ipSeg.slice(0, kIP6Segments).join(':');
1180						ipGroup = 'ip6-' + rawIP.replaceAll(':', '-');
1181						ipName = prefix + '::'; // + '/' + (16 * kIP6Segments);
1182					} else {
1183						ipSeg = ipAddr.split('.');
1184						const prefix = v.ip.split('.').slice(0, kIP4Segments).join('.');
1185						rawIP = ipSeg.slice(0, kIP4Segments).join('.') ;
1186						ipGroup = 'ip4-' + rawIP.replaceAll('.', '-');
1187						ipName = prefix + '.x.x.x'.substring(0, 1+(4-kIP4Segments)*2); // + '/' + (8 * kIP4Segments);
1188					}
1189				}
1190
1191				// check if record already exists:
1192				let ipRec = me._ipRanges.find( it => it.g == ipGroup);
1193				if (!ipRec) {
1194
1195					// ip info record initialised:
1196					ipRec = {
1197						g: ipGroup,
1198						n: ipName,
1199						count: 0
1200					}
1201
1202					// existing record?
1203					if (v._ipRange) {
1204
1205						ipRec.from = v._ipRange.from;
1206						ipRec.to = v._ipRange.to;
1207						ipRec.typ = 'net';
1208
1209					} else { // no known IP range, let's collect necessary information:
1210
1211						// complete the ip info record:
1212						if (ipType == BM_IPVERSION.IPv6) {
1213							ipRec.from = rawIP + ':0000:0000:0000:0000:0000:0000:0000'.substring(0, (8-kIP6Segments)*5);
1214							ipRec.to = rawIP + ':FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF'.substring(0, (8-kIP6Segments)*5);
1215							ipRec.typ = '6';
1216						} else {
1217							ipRec.from = rawIP + '.000.000.000.000'.substring(0, (4-kIP4Segments)*4);
1218							ipRec.to = rawIP + '.255.255.255.254'.substring(0, (4-kIP4Segments)*4);
1219							ipRec.typ = '4';
1220						}
1221					}
1222
1223					me._ipRanges.push(ipRec);
1224				}
1225
1226				// add to counter:
1227				ipRec.count += v._viewCount;
1228
1229			},
1230
1231			getTopBotISPs: function(max) {
1232
1233				const me = BotMon.live.data.analytics;
1234
1235				return me._makeTopList(me._ipRanges, max);
1236			},
1237		},
1238
1239		// information on "known bots":
1240		bots: {
1241			// loads the list of known bots from a JSON file:
1242			init: async function() {
1243				//console.info('BotMon.live.data.bots.init()');
1244
1245				// Load the list of known bots:
1246				BotMon.live.gui.status.showBusy("Loading known bots …");
1247				const url = BotMon._baseDir + 'config/known-bots.json';
1248				try {
1249					const response = await fetch(url);
1250					if (!response.ok) {
1251						throw new Error(`${response.status} ${response.statusText}`);
1252					}
1253
1254					this._list = await response.json();
1255					this._ready = true;
1256
1257				} catch (error) {
1258					BotMon.live.gui.status.setError("Error while loading the known bots file:", error.message);
1259				} finally {
1260					BotMon.live.gui.status.hideBusy("Status: Done.");
1261					BotMon.live.data._dispatch('bots')
1262				}
1263			},
1264
1265			// returns bot info if the clientId matches a known bot, null otherwise:
1266			match: function(agent) {
1267				//console.info('BotMon.live.data.bots.match(',agent,')');
1268
1269				const BotList = BotMon.live.data.bots._list;
1270
1271				// default is: not found!
1272				let botInfo = null;
1273
1274				if (!agent) return null;
1275
1276				// check for known bots:
1277				BotList.find(bot => {
1278					let r = false;
1279					for (let j=0; j<bot.rx.length; j++) {
1280						const rxr = agent.match(new RegExp(bot.rx[j]));
1281						if (rxr) {
1282							botInfo = {
1283								n : bot.n,
1284								id: bot.id,
1285								geo: (bot.geo ? bot.geo : null),
1286								url: bot.url,
1287								v: (rxr.length > 1 ? rxr[1] : -1)
1288							};
1289							r = true;
1290							break;
1291						};
1292					};
1293					return r;
1294				});
1295
1296				// check for unknown bots:
1297				if (!botInfo) {
1298					const botmatch = agent.match(/([\s\d\w\-]*bot|[\s\d\w\-]*crawler|[\s\d\w\-]*spider)[\/\s\w\-;\),\\.$]/i);
1299					if(botmatch) {
1300						botInfo = {'id': ( botmatch[1] || "other_" ), 'n': "Other" + ( botmatch[1] ? " (" + botmatch[1] + ")" : "" ) , "bot": botmatch[1] };
1301					}
1302				}
1303
1304				//console.log("botInfo:", botInfo);
1305				return botInfo;
1306			},
1307
1308
1309			// indicates if the list is loaded and ready to use:
1310			_ready: false,
1311
1312			// the actual bot list is stored here:
1313			_list: []
1314		},
1315
1316		// information on known clients (browsers):
1317		clients: {
1318			// loads the list of known clients from a JSON file:
1319			init: async function() {
1320				//console.info('BotMon.live.data.clients.init()');
1321
1322				// Load the list of known bots:
1323				BotMon.live.gui.status.showBusy("Loading known clients");
1324				const url = BotMon._baseDir + 'config/known-clients.json';
1325				try {
1326					const response = await fetch(url);
1327					if (!response.ok) {
1328						throw new Error(`${response.status} ${response.statusText}`);
1329					}
1330
1331					BotMon.live.data.clients._list = await response.json();
1332					BotMon.live.data.clients._ready = true;
1333
1334				} catch (error) {
1335					BotMon.live.gui.status.setError("Error while loading the known clients file: " + error.message);
1336				} finally {
1337					BotMon.live.gui.status.hideBusy("Status: Done.");
1338					BotMon.live.data._dispatch('clients')
1339				}
1340			},
1341
1342			// returns bot info if the user-agent matches a known bot, null otherwise:
1343			match: function(agent) {
1344				//console.info('BotMon.live.data.clients.match(',agent,')');
1345
1346				let match = {"n": "Unknown", "v": -1, "id": 'null'};
1347
1348				if (agent) {
1349					BotMon.live.data.clients._list.find(client => {
1350						let r = false;
1351						for (let j=0; j<client.rx.length; j++) {
1352							const rxr = agent.match(new RegExp(client.rx[j]));
1353							if (rxr) {
1354								match.n = client.n;
1355								match.v = (rxr.length > 1 ? rxr[1] : -1);
1356								match.id = client.id || null;
1357								r = true;
1358								break;
1359							}
1360						}
1361						return r;
1362					});
1363				}
1364
1365				//console.log(match)
1366				return match;
1367			},
1368
1369			// return the browser name for a browser ID:
1370			getName: function(id) {
1371				const it = BotMon.live.data.clients._list.find(client => client.id == id);
1372				return ( it && it.n ? it.n : "Unknown"); //it.n;
1373			},
1374
1375			// indicates if the list is loaded and ready to use:
1376			_ready: false,
1377
1378			// the actual bot list is stored here:
1379			_list: []
1380
1381		},
1382
1383		// information on known platforms (operating systems):
1384		platforms: {
1385			// loads the list of known platforms from a JSON file:
1386			init: async function() {
1387				//console.info('BotMon.live.data.platforms.init()');
1388
1389				// Load the list of known bots:
1390				BotMon.live.gui.status.showBusy("Loading known platforms");
1391				const url = BotMon._baseDir + 'config/known-platforms.json';
1392				try {
1393					const response = await fetch(url);
1394					if (!response.ok) {
1395						throw new Error(`${response.status} ${response.statusText}`);
1396					}
1397
1398					BotMon.live.data.platforms._list = await response.json();
1399					BotMon.live.data.platforms._ready = true;
1400
1401				} catch (error) {
1402					BotMon.live.gui.status.setError("Error while loading the known platforms file: " + error.message);
1403				} finally {
1404					BotMon.live.gui.status.hideBusy("Status: Done.");
1405					BotMon.live.data._dispatch('platforms')
1406				}
1407			},
1408
1409			// returns bot info if the browser id matches a known platform:
1410			match: function(cid) {
1411				//console.info('BotMon.live.data.platforms.match(',cid,')');
1412
1413				let match = {"n": "Unknown", "id": 'null'};
1414
1415				if (cid) {
1416					BotMon.live.data.platforms._list.find(platform => {
1417						let r = false;
1418						for (let j=0; j<platform.rx.length; j++) {
1419							const rxr = cid.match(new RegExp(platform.rx[j]));
1420							if (rxr) {
1421								match.n = platform.n;
1422								match.v = (rxr.length > 1 ? rxr[1] : -1);
1423								match.id = platform.id || null;
1424								r = true;
1425								break;
1426							}
1427						}
1428						return r;
1429					});
1430				}
1431
1432				return match;
1433			},
1434
1435			// return the platform name for a given ID:
1436			getName: function(id) {
1437				const it = BotMon.live.data.platforms._list.find( pf => pf.id == id);
1438				return ( it ? it.n : 'Unknown' );
1439			},
1440
1441
1442			// indicates if the list is loaded and ready to use:
1443			_ready: false,
1444
1445			// the actual bot list is stored here:
1446			_list: []
1447
1448		},
1449
1450		// storage and functions for the known bot IP-Ranges:
1451		ipRanges: {
1452
1453			init: function() {
1454				//console.log('BotMon.live.data.ipRanges.init()');
1455				// #TODO: Load from separate IP-Ranges file
1456				// load the rules file:
1457				const me = BotMon.live.data;
1458
1459				try {
1460					BotMon.live.data._loadSettingsFile(['user-ipranges', 'known-ipranges'],
1461						(json) => {
1462
1463						// groups can be just saved in the data structure:
1464						if (json.groups && json.groups.constructor.name == 'Array') {
1465							me.ipRanges._groups = json.groups;
1466						}
1467
1468						// groups can be just saved in the data structure:
1469						if (json.ranges && json.ranges.constructor.name == 'Array') {
1470							json.ranges.forEach(range => {
1471								me.ipRanges.add(range);
1472							})
1473						}
1474
1475						// finished loading
1476						BotMon.live.gui.status.hideBusy("Status: Done.");
1477						BotMon.live.data._dispatch('ipranges')
1478					});
1479				} catch (error) {
1480					BotMon.live.gui.status.setError("Error while loading the config file: " + error.message);
1481				}
1482			},
1483
1484			// the actual bot list is stored here:
1485			_list: [],
1486			_groups: [],
1487
1488			add: function(data) {
1489				//console.log('BotMon.live.data.ipRanges.add(',data,')');
1490
1491				const me = BotMon.live.data.ipRanges;
1492
1493				// convert IP address to easier comparable form:
1494				const ip2Num = BotMon.t._ip2Num;
1495
1496				let item = {
1497					'cidr': data.from.replaceAll(/::+/g, '::') + '/' + ( data.m ? data.m : '??' ),
1498					'from': ip2Num(data.from),
1499					'to': ip2Num(data.to),
1500					'm': data.m,
1501					'g': data.g
1502				};
1503				me._list.push(item);
1504
1505			},
1506
1507			getOwner: function(gid) {
1508
1509				const me = BotMon.live.data.ipRanges;
1510
1511				for (let i=0; i < me._groups.length; i++) {
1512					const it = me._groups[i];
1513					if (it.id == gid) {
1514						return it.name;
1515					}
1516				}
1517				return null;
1518			},
1519
1520			match: function(ip) {
1521				//console.log('BotMon.live.data.ipRanges.match(',ip,')');
1522
1523				const me = BotMon.live.data.ipRanges;
1524
1525				// convert IP address to easier comparable form:
1526				const ipNum = BotMon.t._ip2Num(ip);
1527
1528				for (let i=0; i < me._list.length; i++) {
1529					const ipRange = me._list[i];
1530
1531					if (ipNum >= ipRange.from && ipNum <= ipRange.to) {
1532						return ipRange;
1533					}
1534
1535				};
1536				return null;
1537			}
1538		},
1539
1540		// storage for the rules and related functions
1541		rules: {
1542
1543			/**
1544			 * Initializes the rules data.
1545			 *
1546			 * Loads the default config file and the user config file (if present).
1547			 * The default config file is used if the user config file does not have a certain setting.
1548			 * The user config file can override settings from the default config file.
1549			 *
1550			 * The rules are loaded from the `rules` property of the config files.
1551			 * The IP ranges are loaded from the `ipRanges` property of the config files.
1552			 *
1553			 * If an error occurs while loading the config file, it is displayed in the status bar.
1554			 * After the config file is loaded, the status bar is hidden.
1555			 */
1556			init: async function() {
1557				//console.info('BotMon.live.data.rules.init()');
1558
1559				// Load the list of known bots:
1560				BotMon.live.gui.status.showBusy("Loading list of rules …");
1561
1562				// load the rules file:
1563				const me = BotMon.live.data;
1564
1565				try {
1566					BotMon.live.data._loadSettingsFile(['user-config', 'default-config'],
1567						(json) => {
1568
1569							// override the threshold?
1570							if (json.threshold) me._threshold = json.threshold;
1571
1572							// set the rules list:
1573							if (json.rules && json.rules.constructor.name == 'Array') {
1574								me.rules._rulesList = json.rules;
1575							}
1576
1577							BotMon.live.gui.status.hideBusy("Status: Done.");
1578							BotMon.live.data._dispatch('rules')
1579						}
1580					);
1581				} catch (error) {
1582					BotMon.live.gui.status.setError("Error while loading the config file: " + error.message);
1583				}
1584			},
1585
1586			_rulesList: [], // list of rules to find out if a visitor is a bot
1587			_threshold: 100, // above this, it is considered a bot.
1588
1589			// returns a descriptive text for a rule id
1590			getRuleInfo: function(ruleId) {
1591				// console.info('getRuleInfo', ruleId);
1592
1593				// shortcut for neater code:
1594				const me = BotMon.live.data.rules;
1595
1596				for (let i=0; i<me._rulesList.length; i++) {
1597					const rule = me._rulesList[i];
1598					if (rule.id == ruleId) {
1599						return rule;
1600					}
1601				}
1602				return null;
1603
1604			},
1605
1606			// evaluate a visitor for lkikelihood of being a bot
1607			evaluate: function(visitor) {
1608
1609				// shortcut for neater code:
1610				const me = BotMon.live.data.rules;
1611
1612				let r =  {	// evaluation result
1613					'val': 0,
1614					'rules': [],
1615					'isBot': false
1616				};
1617
1618				for (let i=0; i<me._rulesList.length; i++) {
1619					const rule = me._rulesList[i];
1620					const params = ( rule.params ? rule.params : [] );
1621
1622					if (rule.func) { // rule is calling a function
1623						if (me.func[rule.func]) {
1624							if(me.func[rule.func](visitor, ...params)) {
1625								r.val += rule.bot;
1626								r.rules.push(rule.id)
1627							}
1628						} else {
1629							//console.warn("Unknown rule function: “${rule.func}”. Ignoring rule.")
1630						}
1631					}
1632				}
1633
1634				// is a bot?
1635				r.isBot = (r.val >= me._threshold);
1636
1637				return r;
1638			},
1639
1640			// list of functions that can be called by the rules list to evaluate a visitor:
1641			func: {
1642
1643				// check if client is on the list passed as parameter:
1644				matchesClient: function(visitor, ...clients) {
1645
1646					const clientId = ( visitor._client ? visitor._client.id : '');
1647					return clients.includes(clientId);
1648				},
1649
1650				// check if OS/Platform is one of the obsolete ones:
1651				matchesPlatform: function(visitor, ...platforms) {
1652
1653					const pId = ( visitor._platform ? visitor._platform.id : '');
1654
1655					if (visitor._platform.id == null) console.log(visitor._platform);
1656
1657					return platforms.includes(pId);
1658				},
1659
1660				// are there at lest num pages loaded?
1661				smallPageCount: function(visitor, num) {
1662					return (visitor._viewCount <= Number(num));
1663				},
1664
1665				// There was no entry in a specific log file for this visitor:
1666				// note that this will also trigger the "noJavaScript" rule:
1667				noRecord: function(visitor, type) {
1668					if (!visitor._seenBy.includes('srv')) return false; // only if 'srv' is also specified!
1669					return !visitor._seenBy.includes(type);
1670				},
1671
1672				// there are no referrers in any of the page visits:
1673				noReferrer: function(visitor) {
1674
1675					let r = false; // return value
1676					for (let i = 0; i < visitor._pageViews.length; i++) {
1677						if (!visitor._pageViews[i]._ref) {
1678							r = true;
1679							break;
1680						}
1681					}
1682					return r;
1683				},
1684
1685				// test for specific client identifiers:
1686				matchesUserAgent: function(visitor, ...list) {
1687
1688					for (let i=0; i<list.length; i++) {
1689						if (visitor.agent == list[i]) {
1690							return true
1691						}
1692					};
1693					return false;
1694				},
1695
1696				// unusual combinations of Platform and Client:
1697				combinationTest: function(visitor, ...combinations) {
1698
1699					for (let i=0; i<combinations.length; i++) {
1700
1701						if (visitor._platform.id == combinations[i][0]
1702							&& visitor._client.id == combinations[i][1]) {
1703								return true
1704						}
1705					};
1706
1707					return false;
1708				},
1709
1710				// is the IP address from a known bot network?
1711				fromKnownBotIP: function(visitor) {
1712					//console.info('fromKnownBotIP()', visitor.ip);
1713
1714					return visitor.hasOwnProperty('_ipRange');
1715				},
1716
1717				// is the IP address from a specifin known ISP network
1718				fromISPRange: function(visitor, ...isps) {
1719					if (visitor.hasOwnProperty('_ipRange')) {
1720						if (isps.indexOf(visitor._ipRange.g) > -1) {
1721							return true;
1722						}
1723					}
1724					return false;
1725				},
1726
1727				// is the page language mentioned in the client's accepted languages?
1728				// the parameter holds an array of exceptions, i.e. page languages that should be ignored.
1729				matchLang: function(visitor, ...exceptions) {
1730
1731					if (visitor.lang && visitor.accept && exceptions.indexOf(visitor.lang) < 0) {
1732						return (visitor.accept.split(',').indexOf(visitor.lang) < 0);
1733					}
1734					return false;
1735				},
1736
1737				// the "Accept language" header contains certain entries:
1738				clientAccepts: function(visitor, ...languages) {
1739					//console.info('clientAccepts', visitor.accept, languages);
1740
1741					if (visitor.accept && languages) {;
1742						return ( visitor.accept.split(',').filter(lang => languages.includes(lang)).length > 0 );
1743					}
1744					return false;
1745				},
1746
1747				// Is there an accept-language field defined at all?
1748				noAcceptLang: function(visitor) {
1749
1750					if (!visitor.accept || visitor.accept.length <= 0) { // no accept-languages header
1751						return true;
1752					}
1753					// TODO: parametrize this!
1754					return false;
1755				},
1756				// At least x page views were recorded, but they come within less than y seconds
1757				loadSpeed: function(visitor, minItems, maxTime) {
1758
1759					if (visitor._viewCount >= minItems) {
1760						//console.log('loadSpeed', visitor._pageViews.length, minItems, maxTime);
1761
1762						const pvArr = visitor._pageViews.map(pv => pv._lastSeen).sort();
1763
1764						let totalTime = 0;
1765						for (let i=1; i < pvArr.length; i++) {
1766							totalTime += (pvArr[i] - pvArr[i-1]);
1767						}
1768
1769						//console.log('     ', totalTime , Math.round(totalTime / (pvArr.length * 1000)), (( totalTime / pvArr.length ) <= maxTime * 1000), visitor.ip);
1770
1771						return (( totalTime / pvArr.length ) <= maxTime * 1000);
1772					}
1773				},
1774
1775				// Country code matches one of those in the list:
1776				matchesCountry: function(visitor, ...countries) {
1777
1778					// ingore if geoloc is not set or unknown:
1779					if (visitor.geo) {
1780						return (countries.indexOf(visitor.geo) >= 0);
1781					}
1782					return false;
1783				},
1784
1785				// Country does not match one of the given codes.
1786				notFromCountry: function(visitor, ...countries) {
1787
1788					// ingore if geoloc is not set or unknown:
1789					if (visitor.geo && visitor.geo !== 'ZZ') {
1790						return (countries.indexOf(visitor.geo) < 0);
1791					}
1792					return false;
1793				},
1794
1795				// Check if visitor never went beyond a captcha
1796				blockedByCaptcha: function(visitor) {
1797					return (visitor._captcha.Y > 0 && visitor._captcha.N === 0);
1798				},
1799
1800				// Check if visitor came from a whitelisted IP-address
1801				whitelistedByCaptcha: function(visitor) {
1802					return (visitor._captcha.W > 0);
1803				}
1804			}
1805		},
1806
1807		/**
1808		 * Loads a settings file from the specified list of filenames.
1809		 * If the file is successfully loaded, it will call the callback function
1810		 * with the loaded JSON data.
1811		 * If no file can be loaded, it will display an error message.
1812		 *
1813		 * @param {string[]} fns - list of filenames to load
1814		 * @param {function} callback - function to call with the loaded JSON data
1815		 */
1816		_loadSettingsFile: async function(fns, callback) {
1817			//console.info('BotMon.live.data._loadSettingsFile()', fns);
1818
1819			const kJsonExt = '.json';
1820			let loaded = false; // if successfully loaded file
1821
1822			for (let i=0; i<fns.length; i++) {
1823				const filename = fns[i] +kJsonExt;
1824				try {
1825					const response = await fetch(DOKU_BASE + 'lib/plugins/botmon/config/' + filename);
1826					if (!response.ok) {
1827						continue;
1828					} else {
1829						loaded = true;
1830					}
1831					const json = await response.json();
1832					if (callback && typeof callback === 'function') {
1833						callback(json);
1834					}
1835					break;
1836				} catch (e) {
1837					BotMon.live.gui.status.setError("Error while loading the config file: " + filename);
1838				}
1839			}
1840
1841			if (!loaded) {
1842				BotMon.live.gui.status.setError("Could not load a config file.");
1843			}
1844		},
1845
1846		/**
1847		 * Loads a log file (server, page load, or ticker) and parses it.
1848		 * @param {String} type - the type of the log file to load (srv, log, or tck)
1849		 * @param {Function} [onLoaded] - an optional callback function to call after loading is finished.
1850		 */
1851		loadLogFile: async function(type, onLoaded = undefined) {
1852			//console.info('BotMon.live.data.loadLogFile(',type,')');
1853
1854			let typeName = '';
1855			let columns = [];
1856
1857			switch (type) {
1858				case "srv":
1859					typeName = "Server";
1860					columns = ['ts','ip','pg','id','typ','usr','agent','ref','lang','accept','geo','captcha','method'];
1861					break;
1862				case "log":
1863					typeName = "Page load";
1864					columns = ['ts','ip','pg','id','usr','lt','ref','agent'];
1865					break;
1866				case "tck":
1867					typeName = "Ticker";
1868					columns = ['ts','ip','pg','id','agent'];
1869					break;
1870				default:
1871					console.warn(`Unknown log type ${type}.`);
1872					return;
1873			}
1874
1875			// Show the busy indicator and set the visible status:
1876			BotMon.live.gui.status.showBusy(`Loading ${typeName} log file …`);
1877
1878			// compose the URL from which to load:
1879			const url = BotMon._baseDir + `logs/${BotMon._datestr}.${type}.txt`;
1880			//console.log("Loading:",url);
1881
1882			// fetch the data:
1883			try {
1884				const response = await fetch(url);
1885				if (!response.ok) {
1886
1887					throw new Error(`${response.status} ${response.statusText}`);
1888
1889				} else {
1890
1891					// parse the data:
1892					const logtxt = await response.text();
1893					if (logtxt.length <= 0) {
1894						throw new Error(`Empty log file ${url}.`);
1895					}
1896
1897					logtxt.split('\n').forEach((line) => {
1898
1899						const line2 = line.replaceAll(new RegExp('[\x00-\x1F]','g'), "\u{FFFD}").trim();
1900						if (line2 === '') return; // skip empty lines
1901
1902						const cols = line.split('\t');
1903						if (cols.length == 1) return
1904
1905						// assign the columns to an object:
1906						const data = {};
1907						cols.forEach( (colVal,i) => {
1908							const colName = columns[i] || `col${i}`;
1909							const colValue = (colName == 'ts' ? new Date(colVal) : colVal.trim());
1910							data[colName] = colValue;
1911						});
1912
1913						// register the visit in the model:
1914						switch(type) {
1915							case BM_LOGTYPE.SERVER:
1916								BotMon.live.data.model.registerVisit(data, type);
1917								break;
1918							case BM_LOGTYPE.CLIENT:
1919								data.typ = 'js';
1920								BotMon.live.data.model.updateVisit(data);
1921								break;
1922							case BM_LOGTYPE.TICKER:
1923								data.typ = 'js';
1924								BotMon.live.data.model.updateTicks(data);
1925								break;
1926							default:
1927								console.warn(`Unknown log type ${type}.`);
1928								return;
1929						}
1930					});
1931				}
1932
1933			} catch (error) {
1934				BotMon.live.gui.status.setError(`Error while loading the ${typeName} log file: ${error.message} – data may be incomplete.`);
1935			} finally {
1936				BotMon.live.gui.status.hideBusy("Status: Done.");
1937				if (onLoaded) {
1938					onLoaded(); // callback after loading is finished.
1939				}
1940			}
1941		}
1942	},
1943
1944	gui: {
1945		init: function() {
1946			//console.log('BotMon.live.gui.init()');
1947
1948			// init sub-objects:
1949			BotMon.t._callInit(this);
1950		},
1951
1952		tabs: {
1953			init: function() {
1954				//console.log('BotMon.live.gui.tabs.init()');
1955
1956				/* find and add all existing tabs */
1957				document.querySelectorAll('#botmon__admin *[role=tablist]')
1958					.forEach((tablist) => {
1959						tablist.querySelectorAll('*[role=tab]')
1960					.forEach( t => t.addEventListener('click', this._onTabClick) )
1961					});
1962			},
1963
1964			/* callback for tab click */
1965			_onTabClick: function(e) {
1966				//console.log('BotMon.live.gui.tabs._onTabClick()');
1967
1968				/* reusable constants: */
1969				const kAriaSelected = 'aria-selected';
1970				const kAriaControls = 'aria-controls';
1971				const kTrue = 'true';
1972				const kFalse = 'false';
1973				const kHidden = 'hidden';
1974
1975				/* cancel default action */
1976				e.preventDefault();
1977
1978				/* if the active tab is clicked, do nothing: */
1979				let selState = this.getAttribute(kAriaSelected);
1980				if ( selState && selState == kTrue ) {
1981					return;
1982				}
1983
1984				/* find the active tab element: */
1985				var aItem = null;
1986				let tablist = this.parentNode;
1987				while (tablist.getAttribute('role') !== 'tablist') {
1988					tablist = tablist.parentNode;
1989				}
1990
1991				if (tablist.getAttribute('role') == 'tablist') {
1992					let lis = tablist.querySelectorAll('*[role=tab]');
1993					lis.forEach( (it) => {
1994						let selected = it.getAttribute(kAriaSelected);
1995						if ( selected && selected == kTrue ) {
1996							aItem = it;
1997						}
1998					});
1999				}
2000
2001				/* swap the active states: */
2002				this.setAttribute(kAriaSelected, kTrue);
2003				if (aItem) {
2004					aItem.setAttribute(kAriaSelected, kFalse);
2005					let aId = aItem.getAttribute(kAriaControls);
2006					let aObj = document.getElementById(aId);
2007					if (aObj) aObj.hidden = true;
2008				}
2009
2010				/* show the new panel: */
2011				let nId = this.getAttribute(kAriaControls);
2012				let nObj = document.getElementById(nId);
2013				if (nObj) nObj.hidden = false;
2014			}
2015		},
2016
2017		/* The Overview / web metrics section of the live tab */
2018		overview: {
2019			/**
2020			 * Populates the overview part of the today tab with the analytics data.
2021			 *
2022			 * @method make
2023			 * @memberof BotMon.live.gui.overview
2024			 */
2025			make: function() {
2026
2027				const maxItemsPerList = 5; // how many list items to show?
2028				const useCaptcha = BMSettings.useCaptcha || false;
2029
2030				const kNoData = '–'; // shown when data is missing
2031				const kSeparator = ' / ';
2032
2033				// shortcuts for neater code:
2034				const makeElement = BotMon.t._makeElement;
2035				const data = BotMon.live.data.analytics.data;
2036
2037				const botsVsHumans = document.getElementById('botmon__today__botsvshumans');
2038				if (botsVsHumans) {
2039					botsVsHumans.appendChild(makeElement('dt', {}, "Bot statistics"));
2040
2041					for (let i = 0; i <= 5; i++) {
2042						const dd = makeElement('dd');
2043						let title = '';
2044						let value = '';
2045						switch(i) {
2046							case 0:
2047								title = "Known bots visits:";
2048								value =  data.visits.bots || kNoData;
2049								break;
2050							case 1:
2051								title = "Suspected bots visits:";
2052								value = data.visits.suspected || kNoData;
2053								break;
2054							case 2:
2055								title = "Bots-humans ratio visits:";
2056								value = BotMon.t._getRatio(data.visits.suspected + data.visits.bots, data.visits.users + data.visits.humans, 100);
2057								break;
2058							case 3:
2059								title = "Known bots views:";
2060								value = data.views.bots || kNoData;
2061								break;
2062							case 4:
2063								title = "Suspected bots views:";
2064								value = data.views.suspected || kNoData;
2065								break;
2066							case 5:
2067								title = "Bots-humans ratio views:";
2068								value = BotMon.t._getRatio(data.views.suspected + data.views.bots, data.views.users + data.views.humans, 100);
2069								break;
2070							default:
2071								console.warn(`Unknown list type ${i}.`);
2072						}
2073						dd.appendChild(makeElement('span', {}, title));
2074						dd.appendChild(makeElement('strong', {}, value));
2075						botsVsHumans.appendChild(dd);
2076					}
2077				}
2078
2079				// update known bots list:
2080				const botElement = document.getElementById('botmon__botslist'); /* Known bots */
2081				if (botElement) {
2082					botElement.appendChild(makeElement('dt', {}, `Top known bots`));
2083
2084					let botList = BotMon.live.data.analytics.getTopBots(maxItemsPerList);
2085					botList.forEach( (botInfo) => {
2086						const bli = makeElement('dd');
2087						bli.appendChild(makeElement('span', {'class': 'has_icon bot bot_' + botInfo.id }, botInfo.name));
2088						bli.appendChild(makeElement('span', {'class': 'count' }, botInfo.count || kNoData));
2089						botElement.append(bli)
2090					});
2091				}
2092
2093				// update the suspected bot IP ranges list:
2094				const botIps = document.getElementById('botmon__botips');
2095				if (botIps) {
2096					botIps.appendChild(makeElement('dt', {}, "Top bot Networks"));
2097
2098					const ispList = BotMon.live.data.analytics.getTopBotISPs(5);
2099					//console.log(ispList);
2100					ispList.forEach( (netInfo) => {
2101						const li = makeElement('dd');
2102						li.appendChild(makeElement('span', {'class': 'has_icon ipaddr ip' + netInfo.typ }, netInfo.name));
2103						li.appendChild(makeElement('span', {'class': 'count' }, netInfo.count || kNoData));
2104						botIps.append(li)
2105					});
2106				}
2107
2108				// update the top bot countries list:
2109				const botCountries = document.getElementById('botmon__botcountries');
2110				if (botCountries) {
2111					botCountries.appendChild(makeElement('dt', {}, `Top bot Countries`));
2112					const countryList = BotMon.live.data.analytics.getCountryList('bot', 5);
2113					countryList.forEach( (cInfo) => {
2114						const cLi = makeElement('dd');
2115						cLi.appendChild(makeElement('span', {'class': 'has_icon country ctry_' + cInfo.id.toLowerCase() }, cInfo.name));
2116						cLi.appendChild(makeElement('span', {'class': 'count' }, cInfo.count || kNoData));
2117						botCountries.appendChild(cLi);
2118					});
2119				}
2120
2121				// update the webmetrics overview:
2122				const wmoverview = document.getElementById('botmon__today__wm_overview');
2123				if (wmoverview) {
2124
2125					const humanVisits = data.visits.users + data.visits.humans;
2126					const bounceRate = Math.round(100 * (BotMon.live.data.analytics.getBounceCount('users') + BotMon.live.data.analytics.getBounceCount('humans')) / humanVisits);
2127
2128					wmoverview.appendChild(makeElement('dt', {}, "Humans’ metrics"));
2129					for (let i = 0; i <= 5; i++) {
2130						const dd = makeElement('dd');
2131						let title = '';
2132						let value = '';
2133						switch(i) {
2134							case 0:
2135								title = "Registered users visits:";
2136								value = data.visits.users || kNoData;
2137								break;
2138							case 1:
2139								title = "Registered users views:";
2140								value = data.views.users || kNoData;
2141								break;
2142							case 2:
2143								title = "Probably humans visits:";
2144								value = data.visits.humans || kNoData;
2145								break;
2146							case 3:
2147								title = "Probably humans views:";
2148								value = data.views.humans || kNoData;
2149								break;
2150							case 4:
2151								title = "Total human visits / views";
2152								value = (data.visits.users + data.visits.humans || kNoData) + kSeparator + ((data.views.users + data.views.humans) || kNoData);
2153								break;
2154							case 5:
2155								title = "Humans’ bounce rate:";
2156								value = bounceRate + '%';
2157								break;
2158							default:
2159								console.warn(`Unknown list type ${i}.`);
2160						}
2161						dd.appendChild(makeElement('span', {}, title));
2162						dd.appendChild(makeElement('strong', {}, value));
2163						wmoverview.appendChild(dd);
2164					}
2165				}
2166
2167				// update the webmetrics clients list:
2168				const wmclients = document.getElementById('botmon__today__wm_clients');
2169				if (wmclients) {
2170
2171					wmclients.appendChild(makeElement('dt', {}, "Browsers"));
2172
2173					const clientList = BotMon.live.data.analytics.getTopBrowsers(maxItemsPerList);
2174					if (clientList) {
2175						clientList.forEach( (cInfo) => {
2176							const cDd = makeElement('dd');
2177							cDd.appendChild(makeElement('span', {'class': 'has_icon client cl_' + cInfo.id }, ( cInfo.name ? cInfo.name : cInfo.id)));
2178							cDd.appendChild(makeElement('span', {
2179								'class': 'count',
2180								'title': cInfo.count + " page views"
2181							}, cInfo.pct.toFixed(1) + '%'));
2182							wmclients.appendChild(cDd);
2183						});
2184					}
2185				}
2186
2187				// update the webmetrics platforms list:
2188				const wmplatforms = document.getElementById('botmon__today__wm_platforms');
2189				if (wmplatforms) {
2190
2191					wmplatforms.appendChild(makeElement('dt', {}, "Platforms"));
2192
2193					const pfList = BotMon.live.data.analytics.getTopPlatforms(maxItemsPerList);
2194					if (pfList) {
2195						pfList.forEach( (pInfo) => {
2196							const pDd = makeElement('dd');
2197							pDd.appendChild(makeElement('span', {'class': 'has_icon platform pf_' + pInfo.id }, ( pInfo.name ? pInfo.name : pInfo.id)));
2198							pDd.appendChild(makeElement('span', {
2199								'class': 'count',
2200								'title': pInfo.count + " page views"
2201							}, pInfo.pct.toFixed(1) + '%'));
2202							wmplatforms.appendChild(pDd);
2203						});
2204					}
2205				}
2206
2207				// update the top bot countries list:
2208				const usrCountries = document.getElementById('botmon__today__wm_countries');
2209				if (usrCountries) {
2210					usrCountries.appendChild(makeElement('dt', {}, `Top visitor Countries:`));
2211					const usrCtryList = BotMon.live.data.analytics.getCountryList('human', 5);
2212					usrCtryList.forEach( (cInfo) => {
2213						const cLi = makeElement('dd');
2214						cLi.appendChild(makeElement('span', {'class': 'has_icon country ctry_' + cInfo.id.toLowerCase() }, cInfo.name));
2215						cLi.appendChild(makeElement('span', {'class': 'count' }, cInfo.count || kNoData));
2216						usrCountries.appendChild(cLi);
2217					});
2218				}
2219
2220				// update the top pages;
2221				const wmpages = document.getElementById('botmon__today__wm_pages');
2222				if (wmpages) {
2223
2224					wmpages.appendChild(makeElement('dt', {}, "Top pages"));
2225
2226					const pgList = BotMon.live.data.analytics.getTopPages(maxItemsPerList);
2227					if (pgList) {
2228						pgList.forEach( (pgInfo) => {
2229							const pgDd = makeElement('dd');
2230							pgDd.appendChild(makeElement('a', {
2231								'class': 'page_icon',
2232								'href': DOKU_BASE + 'doku.php?id=' + encodeURIComponent(pgInfo.id),
2233								'target': 'preview',
2234								'title': "PageID: " + pgInfo.id
2235							}, pgInfo.id));
2236							pgDd.appendChild(makeElement('span', {
2237								'class': 'count',
2238								'title': pgInfo.count + " page views"
2239							}, pgInfo.count || kNoData));
2240							wmpages.appendChild(pgDd);
2241						});
2242					}
2243				}
2244
2245				// update the top referrers;
2246				const wmreferers = document.getElementById('botmon__today__wm_referers');
2247				if (wmreferers) {
2248
2249					wmreferers.appendChild(makeElement('dt', {}, "Referers"));
2250
2251					const refList = BotMon.live.data.analytics.getTopReferers(maxItemsPerList);
2252					if (refList) {
2253						refList.forEach( (rInfo) => {
2254							const rDd = makeElement('dd');
2255							rDd.appendChild(makeElement('span', {'class': 'has_icon referer ref_' + rInfo.id }, rInfo.name));
2256							rDd.appendChild(makeElement('span', {
2257								'class': 'count',
2258								'title': rInfo.count + " references"
2259							}, rInfo.pct.toFixed(1) + '%'));
2260							wmreferers.appendChild(rDd);
2261						});
2262					}
2263				}
2264
2265				// Update Captcha statistics:
2266				const captchaStatsBlock = document.getElementById('botmon__today__captcha');
2267				if (captchaStatsBlock) {
2268
2269					// first column:
2270					const captchaHumans = document.getElementById('botmon__today__cp_humans');
2271					if (captchaHumans) {
2272						captchaHumans.appendChild(makeElement('dt', {}, "Probably humans:"));
2273
2274						for (let i = 0; i <= 4; i++) {
2275							const dd = makeElement('dd');
2276							let title = '';
2277							let value = '';
2278							switch(i) {
2279								case 0:
2280									title = "Solved:";
2281									value = data.captcha.humans_passed;
2282									break;
2283								case 1:
2284									title = "Blocked:";
2285									value = data.captcha.humans_blocked;
2286									break;
2287								case 2:
2288									title = "Whitelisted:";
2289									value = data.captcha.humans_whitelisted;
2290									break;
2291								case 3:
2292									title = "Total visits:";
2293									value = data.visits.humans;
2294									break;
2295								case 4:
2296									title = "Pct. blocked:";
2297									value = (data.captcha.humans_blocked / data.visits.humans * 100).toFixed(0) + '%';
2298									break;
2299								default:
2300									console.warn(`Unknown list type ${i}.`);
2301							}
2302							dd.appendChild(makeElement('span', {}, title));
2303							dd.appendChild(makeElement('strong', {}, value || kNoData));
2304							captchaHumans.appendChild(dd);
2305						}
2306
2307					}
2308
2309					// second column:
2310					const captchaSus = document.getElementById('botmon__today__cp_sus');
2311					if (captchaSus) {
2312						captchaSus.appendChild(makeElement('dt', {}, "Suspected bots:"));
2313
2314						for (let i = 0; i <= 4; i++) {
2315							const dd = makeElement('dd');
2316							let title = '';
2317							let value = '';
2318							switch(i) {
2319								case 0:
2320									title = "Solved:";
2321									value = data.captcha.sus_passed;
2322									break;
2323								case 1:
2324									title = "Blocked:";
2325									value = data.captcha.sus_blocked;
2326									break;
2327								case 2:
2328									title = "Whitelisted:";
2329									value = data.captcha.sus_whitelisted;
2330									break;
2331								case 3:
2332									title = "Total visits:";
2333									value = data.visits.suspected;
2334									break;
2335								case 4:
2336									title = "Pct. blocked:";
2337									value = (data.captcha.sus_blocked / data.visits.suspected * 100).toFixed(0) + '%';
2338									break;
2339								default:
2340									console.warn(`Unknown list type ${i}.`);
2341							}
2342							dd.appendChild(makeElement('span', {}, title));
2343							dd.appendChild(makeElement('strong', {}, value || kNoData));
2344							captchaSus.appendChild(dd);
2345						}
2346
2347					}
2348
2349					// Third column:
2350					const captchaBots = document.getElementById('botmon__today__cp_bots');
2351					if (captchaBots) {
2352						captchaBots.appendChild(makeElement('dt', {}, "Known bots:"));
2353
2354						for (let i = 0; i <= 4; i++) {
2355							const dd = makeElement('dd');
2356							let title = '';
2357							let value = '';
2358							switch(i) {
2359								case 0:
2360									title = "Solved:";
2361									value = data.captcha.bots_passed;
2362									break;
2363								case 1:
2364									title = "Blocked:";
2365									value = data.captcha.bots_blocked;
2366									break;
2367								case 2:
2368									title = "Whitelisted:";
2369									value = data.captcha.bots_whitelisted;
2370									break;
2371								case 3:
2372									title = "Total visits:";
2373									value = data.visits.bots;
2374									break;
2375								case 4:
2376									title = "Pct. blocked:";
2377									value = (data.captcha.bots_blocked / data.visits.bots * 100).toFixed(0) + '%';
2378									break;
2379								default:
2380									console.warn(`Unknown list type ${i}.`);
2381							}
2382							dd.appendChild(makeElement('span', {}, title));
2383							dd.appendChild(makeElement('strong', {}, value || kNoData));
2384							captchaBots.appendChild(dd);
2385						}
2386
2387					}
2388				}
2389			}
2390		},
2391
2392		status: {
2393			setText: function(txt) {
2394				const el = document.getElementById('botmon__today__status');
2395				if (el && BotMon.live.gui.status._errorCount <= 0) {
2396					el.innerText = txt;
2397				}
2398			},
2399
2400			setTitle: function(html) {
2401				const el = document.getElementById('botmon__today__title');
2402				if (el) {
2403					el.innerHTML = html;
2404				}
2405			},
2406
2407			setError: function(txt) {
2408				console.error(txt);
2409				BotMon.live.gui.status._errorCount += 1;
2410				const el = document.getElementById('botmon__today__status');
2411				if (el) {
2412					el.innerText = "Data may be incomplete.";
2413					el.classList.add('error');
2414				}
2415			},
2416			_errorCount: 0,
2417
2418			showBusy: function(txt = null) {
2419				BotMon.live.gui.status._busyCount += 1;
2420				const el = document.getElementById('botmon__today__busy');
2421				if (el) {
2422					el.style.display = 'inline-block';
2423				}
2424				if (txt) BotMon.live.gui.status.setText(txt);
2425			},
2426			_busyCount: 0,
2427
2428			hideBusy: function(txt = null) {
2429				const el = document.getElementById('botmon__today__busy');
2430				BotMon.live.gui.status._busyCount -= 1;
2431				if (BotMon.live.gui.status._busyCount <= 0) {
2432					if (el) el.style.display = 'none';
2433					if (txt) BotMon.live.gui.status.setText(txt);
2434				}
2435			}
2436		},
2437
2438		lists: {
2439			init: function() {
2440
2441				// function shortcut:
2442				const makeElement = BotMon.t._makeElement;
2443
2444				const parent = document.getElementById('botmon__today__visitorlists');
2445				if (parent) {
2446
2447					for (let i=0; i < 4; i++) {
2448
2449						// change the id and title by number:
2450						let listTitle = '';
2451						let listId = '';
2452						let infolink = null;
2453						switch (i) {
2454							case 0:
2455								listTitle = "Registered users";
2456								listId = 'users';
2457								break;
2458							case 1:
2459								listTitle = "Probably humans";
2460								listId = 'humans';
2461								break;
2462							case 2:
2463								listTitle = "Suspected bots";
2464								listId = 'suspectedBots';
2465								infolink = 'https://leib.be/sascha/projects/dokuwiki/botmon/info/suspected_bots';
2466								break;
2467							case 3:
2468								listTitle = "Known bots";
2469								listId = 'knownBots';
2470								infolink = 'https://leib.be/sascha/projects/dokuwiki/botmon/info/known_bots';
2471								break;
2472							default:
2473								console.warn('Unknown list number.');
2474						}
2475
2476						const details = makeElement('details', {
2477							'data-group': listId,
2478							'data-loaded': false
2479						});
2480						const title = details.appendChild(makeElement('summary'));
2481						title.appendChild(makeElement('span', {'class': 'title'}, listTitle));
2482						if (infolink) {
2483							title.appendChild(makeElement('a', {
2484								'class': 'ext_info',
2485								'target': '_blank',
2486								'href': infolink,
2487								'title': "More information"
2488							}, "Info"));
2489						}
2490						details.addEventListener("toggle", this._onDetailsToggle);
2491
2492						parent.appendChild(details);
2493
2494					}
2495				}
2496			},
2497
2498			_onDetailsToggle: function(e) {
2499				//console.info('BotMon.live.gui.lists._onDetailsToggle()');
2500
2501				const target = e.target;
2502
2503				if (target.getAttribute('data-loaded') == 'false') { // only if not loaded yet
2504					target.setAttribute('data-loaded', 'loading');
2505
2506					const fillType = target.getAttribute('data-group');
2507					const fillList = BotMon.live.data.analytics.groups[fillType];
2508					if (fillList && fillList.length > 0) {
2509
2510						const ul = BotMon.t._makeElement('ul');
2511
2512						fillList.forEach( (it) => {
2513							ul.appendChild(BotMon.live.gui.lists._makeVisitorItem(it, fillType));
2514						});
2515
2516						target.appendChild(ul);
2517						target.setAttribute('data-loaded', 'true');
2518					} else {
2519						target.setAttribute('data-loaded', 'false');
2520					}
2521
2522				}
2523			},
2524
2525			_makeVisitorItem: function(data, type) {
2526
2527				// shortcuts for neater code:
2528				const make = BotMon.t._makeElement;
2529				const model = BotMon.live.data.model;
2530
2531				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
2532				if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
2533
2534				const platformName = (data._platform ? data._platform.n : 'Unknown');
2535				const clientName = (data._client ? data._client.n: 'Unknown');
2536
2537				const sumClass = ( !data._seenBy || data._seenBy.indexOf(BM_LOGTYPE.SERVER) < 0 ? 'noServer' : 'hasServer');
2538
2539				// combine with other networks?
2540				const combineNets = (BMSettings.hasOwnProperty('combineNets') ? BMSettings['combineNets'] : true)
2541					&& data.hasOwnProperty('_ipRange');
2542
2543				const li = make('li'); // root list item
2544				const details = make('details');
2545				const summary = make('summary', {
2546					'class': sumClass
2547				});
2548				details.appendChild(summary);
2549
2550				const span1 = make('span'); /* left-hand group */
2551
2552				/*if (data._type !== BM_USERTYPE.KNOWN_BOT) { // No platform/client for bots // disabled because no longer relevant
2553					span1.appendChild(make('span', { // Platform
2554						'class': 'icon_only platform pf_' + (data._platform ? data._platform.id : 'unknown'),
2555						'title': "Platform: " + platformName
2556					}, platformName));
2557
2558					span1.appendChild(make('span', { // Client
2559						'class': 'icon_only client client cl_' + (data._client ? data._client.id : 'unknown'),
2560						'title': "Client: " + clientName
2561					}, clientName));
2562				}*/
2563
2564				// identifier:
2565				if (data._type == BM_USERTYPE.KNOWN_BOT) { /* Bot only */
2566
2567					const botName = ( data._bot && data._bot.n ? data._bot.n : "Unknown");
2568					span1.appendChild(make('span', { /* Bot */
2569						'class': 'has_icon bot bot_' + (data._bot ? data._bot.id : 'unknown'),
2570						'title': "Bot: " + botName
2571					}, botName));
2572
2573				} else if (data._type == BM_USERTYPE.KNOWN_USER) { /* User only */
2574
2575					span1.appendChild(make('span', { /* User */
2576						'class': 'has_icon user_known',
2577						'title': "User: " + data.usr
2578					}, data.usr));
2579
2580				} else { /* others */
2581
2582					if (combineNets) {
2583
2584						const ispName = BotMon.live.data.ipRanges.getOwner( data._ipRange.g ) || data._ipRange.g;
2585
2586						span1.appendChild(make('span', { // IP-Address
2587							'class': 'has_icon ipaddr ipnet',
2588							'title': "IP-Range: " + data._ipRange.g
2589						}, ispName));
2590
2591					} else {
2592
2593						span1.appendChild(make('span', { // IP-Address
2594							'class': 'has_icon ipaddr ip' + ipType,
2595							'title': "IP-Address: " + data.ip
2596						}, data.ip));
2597					}
2598				}
2599
2600				span1.appendChild(make('span', { /* page views */
2601					'class': 'has_icon pageseen',
2602					'title': data._pageViews.length + " page load(s)"
2603				}, data._pageViews.length));
2604
2605
2606				// referer icons:
2607				if ((data._type == BM_USERTYPE.PROBABLY_HUMAN || data._type == BM_USERTYPE.LIKELY_BOT) && data.ref) {
2608					const refInfo = BotMon.live.data.analytics.getRefererInfo(data.ref);
2609					span1.appendChild(make('span', {
2610						'class': 'icon_only referer ref_' + refInfo.id,
2611						'title': "Referer: " + data.ref
2612					}, refInfo.n));
2613				}
2614
2615				summary.appendChild(span1);
2616				const span2 = make('span'); /* right-hand group */
2617
2618				// country flag:
2619				if (!combineNets) { // not for combined networks
2620					if (data.geo && data.geo !== 'ZZ') {
2621						span2.appendChild(make('span', {
2622							'class': 'icon_only country ctry_' + data.geo.toLowerCase(),
2623							'data-ctry': data.geo,
2624							'title': "Country: " + ( data._country || "Unknown")
2625						}, ( data._country || "Unknown") ));
2626					}
2627				}
2628
2629				span2.appendChild(make('span', { // seen-by icon:
2630					'class': 'icon_only seenby sb_' + data._seenBy.join(''),
2631					'title': "Seen by: " + data._seenBy.join('+')
2632				}, data._seenBy.join(', ')));
2633
2634				// captcha status:
2635				const cCode = ( data._captcha ? data._captcha._str() : '');
2636				if (cCode !== '') {
2637					const cTitle = model._makeCaptchaTitle(data._captcha)
2638					span2.appendChild(make('span', { // captcha status
2639						'class': 'icon_only captcha cap_' + cCode,
2640						'title': "Captcha-status: " + cTitle
2641					}, cTitle));
2642				}
2643
2644				summary.appendChild(span2);
2645
2646				// add details expandable section:
2647				details.appendChild(BotMon.live.gui.lists._makeVisitorDetails(data, type));
2648
2649				li.appendChild(details);
2650				return li;
2651			},
2652
2653			_makeVisitorDetails: function(data, type) {
2654
2655				// shortcuts for neater code:
2656				const make = BotMon.t._makeElement;
2657				const model = BotMon.live.data.model;
2658
2659				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
2660				if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
2661				const platformName = (data._platform ? data._platform.n : 'Unknown');
2662				const clientName = (data._client ? data._client.n: 'Unknown');
2663				const combinedItem = data.hasOwnProperty('_ipRange');
2664
2665				const dl = make('dl', {'class': 'visitor_details'});
2666
2667				if (data._type == BM_USERTYPE.KNOWN_BOT) {
2668
2669					dl.appendChild(make('dt', {}, "Bot name:")); /* bot info */
2670					dl.appendChild(make('dd', {'class': 'icon_only bot bot_' + (data._bot ? data._bot.id : 'unknown')},
2671						(data._bot ? data._bot.n : 'Unknown')));
2672
2673					if (data._bot && data._bot.url) {
2674						dl.appendChild(make('dt', {}, "Bot info:")); /* bot info */
2675						const botInfoDd = dl.appendChild(make('dd'));
2676						botInfoDd.appendChild(make('a', {
2677							'href': data._bot.url,
2678							'target': '_blank'
2679						}, data._bot.url)); /* bot info link*/
2680
2681					}
2682
2683				} else if (!combinedItem) { /* not for bots or combined items */
2684
2685					dl.appendChild(make('dt', {}, "Client:")); /* client */
2686					dl.appendChild(make('dd', {'class': 'has_icon client cl_' + (data._client ? data._client.id : 'unknown')},
2687						clientName + ( data._client.v > 0 ? ' (' + data._client.v + ')' : '' ) ));
2688
2689					dl.appendChild(make('dt', {}, "Platform:")); /* platform */
2690					dl.appendChild(make('dd', {'class': 'has_icon platform pf_' + (data._platform ? data._platform.id : 'unknown')},
2691						platformName + ( data._platform.v > 0 ? ' (' + data._platform.v + ')' : '' ) ));
2692
2693					dl.appendChild(make('dt', {}, "IP-Address:"));
2694					const ipItem = make('dd', {'class': 'has_icon ipaddr ip' + ipType});
2695						ipItem.appendChild(make('span', {'class': 'address'} , data.ip));
2696						ipItem.appendChild(make('a', {
2697							'class': 'icon_only extlink ipinfo',
2698							'href': `https://ipinfo.io/${encodeURIComponent(data.ip)}`,
2699							'target': 'ipinfo',
2700							'title': "View this address on IPInfo.io"
2701						} , "DNS Info"));
2702						ipItem.appendChild(make('a', {
2703							'class': 'icon_only extlink abuseipdb',
2704							'href': `https://www.abuseipdb.com/check/${encodeURIComponent(data.ip)}`,
2705							'target': 'abuseipdb',
2706							'title': "Check this address on AbuseIPDB.com"
2707						} , "Check on AbuseIPDB"));
2708					dl.appendChild(ipItem);
2709
2710					dl.appendChild(make('dt', {}, "User-Agent:"));
2711					dl.appendChild(make('dd', {'class': 'agent'}, data.agent));
2712
2713					dl.appendChild(make('dt', {}, "Languages:"));
2714					dl.appendChild(make('dd', {'class': 'langs'}, ` [${data.accept}]`));
2715
2716					dl.appendChild(make('dt', {}, "Session ID:"));
2717					dl.appendChild(make('dd', {'class': 'has_icon session typ_' + data.typ}, data.id));
2718
2719					if (data.geo && data.geo !=='') {
2720						dl.appendChild(make('dt', {}, "Location:"));
2721						dl.appendChild(make('dd', {
2722							'class': 'has_icon country ctry_' + data.geo.toLowerCase(),
2723							'data-ctry': data.geo,
2724							'title': "Country: " + data._country
2725						}, data._country + ' (' + data.geo + ')'));
2726					}
2727				}
2728
2729				if (Math.abs(data._lastSeen - data._firstSeen) < 100) {
2730					dl.appendChild(make('dt', {}, "Seen:"));
2731					dl.appendChild(make('dd', {'class': 'seen'}, data._firstSeen.toLocaleString()));
2732				} else {
2733					dl.appendChild(make('dt', {}, "First seen:"));
2734					dl.appendChild(make('dd', {'class': 'firstSeen'}, data._firstSeen.toLocaleString()));
2735					dl.appendChild(make('dt', {}, "Last seen:"));
2736					dl.appendChild(make('dd', {'class': 'lastSeen'}, data._lastSeen.toLocaleString()));
2737				}
2738
2739				dl.appendChild(make('dt', {}, "Actions:"));
2740				dl.appendChild(make('dd', {'class': 'views'},
2741					"Page loads: " + data._loadCount.toString() +
2742					( data._captcha['Y'] > 0 ? ", captchas: " + data._captcha['Y'].toString() : '') +
2743					", views: " + data._viewCount.toString()
2744				));
2745
2746				if (data.ref && data.ref !== '') {
2747					dl.appendChild(make('dt', {}, "Referrer:"));
2748
2749					const refInfo = BotMon.live.data.analytics.getRefererInfo(data.ref);
2750					const refDd = dl.appendChild(make('dd', {
2751						'class': 'has_icon referer ref_' + refInfo.id
2752					}));
2753					refDd.appendChild(make('a', {
2754						'href': data.ref,
2755						'target': 'refTarget'
2756					}, data.ref));
2757				}
2758
2759				if (data.captcha && data.captcha !=='') {
2760					dl.appendChild(make('dt', {}, "Captcha-status:"));
2761					dl.appendChild(make('dd', {
2762						'class': 'captcha'
2763					}, model._makeCaptchaTitle(data._captcha)));
2764				}
2765
2766				dl.appendChild(make('dt', {}, "Seen by:"));
2767				dl.appendChild(make('dd', {'class': 'has_icon seenby sb_' + data._seenBy.join('')}, data._seenBy.join(', ') ));
2768
2769				dl.appendChild(make('dt', {}, "Visited pages:"));
2770				const pagesDd = make('dd', {'class': 'pages'});
2771				const pageList = make('ul');
2772
2773				/* list all page views */
2774				data._pageViews.sort( (a, b) => a._firstSeen - b._firstSeen );
2775				data._pageViews.forEach( (page) => {
2776					pageList.appendChild(BotMon.live.gui.lists._makePageViewItem(page, combinedItem));
2777				});
2778				pagesDd.appendChild(pageList);
2779				dl.appendChild(pagesDd);
2780
2781				/* bot evaluation rating */
2782				if (data._type !== BM_USERTYPE.KNOWN_BOT && data._type !== BM_USERTYPE.KNOWN_USER) {
2783					dl.appendChild(make('dt', undefined, "Bot rating:"));
2784					dl.appendChild(make('dd', {'class': 'bot-rating'}, ( data._botVal ? data._botVal : '0' ) + ' (of ' + BotMon.live.data.rules._threshold + ')'));
2785
2786					/* add bot evaluation details: */
2787					if (data._eval) {
2788						dl.appendChild(make('dt', {}, "Bot evaluation:"));
2789						const evalDd = make('dd', {'class': 'eval'});
2790						const testList = make('ul');
2791						data._eval.forEach( test => {
2792
2793							const tObj = BotMon.live.data.rules.getRuleInfo(test);
2794							let tDesc = tObj ? tObj.desc : test;
2795
2796							// special case for Bot IP range test:
2797							if (tObj.func == 'fromKnownBotIP') {
2798								const rangeInfo = BotMon.live.data.ipRanges.match(data.ip);
2799								if (rangeInfo) {
2800									const owner = BotMon.live.data.ipRanges.getOwner(rangeInfo.g);
2801									tDesc += ' (range: “' + rangeInfo.cidr + '”, ' + owner + ')';
2802								}
2803							}
2804
2805							// create the entry field
2806							const tstLi = make('li');
2807							tstLi.appendChild(make('span', {
2808								'data-testid': test
2809							}, tDesc));
2810							tstLi.appendChild(make('span', {}, ( tObj ? tObj.bot : '—') ));
2811							testList.appendChild(tstLi);
2812						});
2813
2814						// add total row
2815						const tst2Li = make('li', {
2816							'class': 'total'
2817						});
2818						/*tst2Li.appendChild(make('span', {}, "Total:"));
2819						tst2Li.appendChild(make('span', {}, data._botVal));
2820						testList.appendChild(tst2Li);*/
2821
2822						evalDd.appendChild(testList);
2823						dl.appendChild(evalDd);
2824					}
2825				}
2826
2827				// for debugging only. Disable on production:
2828				/*dl.appendChild(make('dt', {}, "Debug info:"));
2829				const dbgDd = make('dd', {'class': 'debug'});
2830				dbgDd.innerHTML = '<pre>' + JSON.stringify(data, null, 4) + '</pre>';
2831				dl.appendChild(dbgDd);*/
2832
2833				// return the element to add to the UI:
2834				return dl;
2835			},
2836
2837			// make a page view item:
2838			_makePageViewItem: function(page, moreInfo) {
2839				console.log("makePageViewItem:",page);
2840
2841				// shortcut for neater code:
2842				const make = BotMon.t._makeElement;
2843
2844				// the actual list item:
2845				const pgLi = make('li');
2846				if (moreInfo) pgLi.classList.add('detailled');
2847
2848				const row1 = make('div', {'class': 'row'});
2849
2850					row1.appendChild(make('a', { // page id is the left group
2851						'href': DOKU_BASE + 'doku.php?id=' + encodeURIComponent(page.pg),
2852						'target': 'preview',
2853						'hreflang': page.lang,
2854						'title': "PageID: " + page.pg
2855					}, page.pg)); /* DW Page ID */
2856
2857					const rightGroup = row1.appendChild(make('div')); // right-hand group
2858
2859						// get the time difference:
2860						rightGroup.appendChild(make('span', {
2861							'class': 'first-seen',
2862							'title': "First visited: " + page._firstSeen.toLocaleString() + " UTC"
2863						}, BotMon.t._formatTime(page._firstSeen)));
2864
2865						rightGroup.appendChild(make('span', { /* page loads */
2866							'class': 'has_icon pageviews',
2867							'title': page._viewCount.toString() + " page load(s)"
2868						}, page._viewCount.toString()));
2869
2870				pgLi.appendChild(row1);
2871
2872				/* LINE 2 */
2873
2874				const row2 = make('div', {'class': 'row'});
2875
2876					// page referrer:
2877					if (page._ref) {
2878						row2.appendChild(make('span', {
2879							'class': 'referer',
2880							'title': "Referrer: " + page._ref.href
2881						}, page._ref.hostname));
2882					} else {
2883						row2.appendChild(make('span', {
2884							'class': 'referer'
2885						}, "No referer"));
2886					}
2887
2888					// visit duration:
2889					let visitTimeStr = "Bounce";
2890					const visitDuration = page._lastSeen.getTime() - page._firstSeen.getTime();
2891					if (visitDuration > 0) {
2892						visitTimeStr = Math.floor(visitDuration / 1000) + "s";
2893					}
2894					const tDiff = BotMon.t._formatTimeDiff(page._firstSeen, page._lastSeen);
2895					if (tDiff) {
2896						row2.appendChild(make('span', {'class': 'visit-length', 'title': 'Last seen: ' + page._lastSeen.toLocaleString()}, tDiff));
2897					} else {
2898						row2.appendChild(make('span', {
2899							'class': 'bounce',
2900							'title': "Visitor bounced"}, "Bounce"));
2901					}
2902
2903				pgLi.appendChild(row2);
2904
2905				return pgLi;
2906			}
2907		}
2908	}
2909};
2910
2911/* launch only if the BotMon admin panel is open: */
2912if (document.getElementById('botmon__admin')) {
2913	BotMon.init();
2914}