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