xref: /plugin/botmon/script.js (revision abfc901f99b90a4c2e4de802fce1a276b719177a)
1"use strict";
2/* DokuWiki BotMon Plugin Script file */
3/* 04.09.2025 - 0.1.8 - pre-release */
4/* Authors: Sascha Leib <ad@hominem.info> */
5
6const BotMon = {
7
8	init: function() {
9		//console.info('BotMon.init()');
10
11		// find the plugin basedir:
12		this._baseDir = document.currentScript.src.substring(0, document.currentScript.src.indexOf('/exe/'))
13			+ '/plugins/botmon/';
14
15		// read the page language from the DOM:
16		this._lang = document.getRootNode().documentElement.lang || this._lang;
17
18		// get the time offset:
19		this._timeDiff = BotMon.t._getTimeOffset();
20
21		// init the sub-objects:
22		BotMon.t._callInit(this);
23	},
24
25	_baseDir: null,
26	_lang: 'en',
27	_today: (new Date()).toISOString().slice(0, 10),
28	_timeDiff: '',
29
30	/* internal tools */
31	t: {
32
33		/* helper function to call inits of sub-objects */
34		_callInit: function(obj) {
35			//console.info('BotMon.t._callInit(obj=',obj,')');
36
37			/* call init / _init on each sub-object: */
38			Object.keys(obj).forEach( (key,i) => {
39				const sub = obj[key];
40				let init = null;
41				if (typeof sub === 'object' && sub.init) {
42					init = sub.init;
43				}
44
45				// bind to object
46				if (typeof init == 'function') {
47					const init2 = init.bind(sub);
48					init2(obj);
49				}
50			});
51		},
52
53		/* helper function to calculate the time difference to UTC: */
54		_getTimeOffset: function() {
55			const now = new Date();
56			let offset = now.getTimezoneOffset(); // in minutes
57			const sign = Math.sign(offset); // +1 or -1
58			offset = Math.abs(offset); // always positive
59
60			let hours = 0;
61			while (offset >= 60) {
62				hours += 1;
63				offset -= 60;
64			}
65			return ( hours > 0 ? sign * hours + ' h' : '') + (offset > 0 ? ` ${offset} min` : '');
66		},
67
68		/* helper function to create a new element with all attributes and text content */
69		_makeElement: function(name, atlist = undefined, text = undefined) {
70			var r = null;
71			try {
72				r = document.createElement(name);
73				if (atlist) {
74					for (let attr in atlist) {
75						r.setAttribute(attr, atlist[attr]);
76					}
77				}
78				if (text) {
79					r.textContent = text.toString();
80				}
81			} catch(e) {
82				console.error(e);
83			}
84			return r;
85		}
86	}
87};
88
89/* everything specific to the "Today" tab is self-contained in the "live" object: */
90BotMon.live = {
91	init: function() {
92		//console.info('BotMon.live.init()');
93
94		// set the title:
95		const tDiff = '(<abbr title="Coordinated Universal Time">UTC</abbr>' + (BotMon._timeDiff != '' ? `, ${BotMon._timeDiff}` : '' ) + ')';
96		BotMon.live.gui.status.setTitle(`Data for <time datetime=${BotMon._today}>${BotMon._today}</time> ${tDiff}`);
97
98		// init sub-objects:
99		BotMon.t._callInit(this);
100	},
101
102	data: {
103		init: function() {
104			//console.info('BotMon.live.data.init()');
105
106			// call sub-inits:
107			BotMon.t._callInit(this);
108		},
109
110		// this will be called when the known json files are done loading:
111		_dispatch: function(file) {
112			//console.info('BotMon.live.data._dispatch(,',file,')');
113
114			// shortcut to make code more readable:
115			const data = BotMon.live.data;
116
117			// set the flags:
118			switch(file) {
119				case 'bots':
120					data._dispatchBotsLoaded = true;
121					break;
122				case 'clients':
123					data._dispatchClientsLoaded = true;
124					break;
125				case 'platforms':
126					data._dispatchPlatformsLoaded = true;
127					break;
128				default:
129					// ignore
130			}
131
132			// are all the flags set?
133			if (data._dispatchBotsLoaded && data._dispatchClientsLoaded && data._dispatchPlatformsLoaded) {
134				// chain the log files loading:
135				BotMon.live.data.loadLogFile('srv', BotMon.live.data._onServerLogLoaded);
136			}
137		},
138		// flags to track which data files have been loaded:
139		_dispatchBotsLoaded: false,
140		_dispatchClientsLoaded: false,
141		_dispatchPlatformsLoaded: false,
142
143		// event callback, after the server log has been loaded:
144		_onServerLogLoaded: function() {
145			//console.info('BotMon.live.data._onServerLogLoaded()');
146
147			// chain the client log file to load:
148			BotMon.live.data.loadLogFile('log', BotMon.live.data._onClientLogLoaded);
149		},
150
151		// event callback, after the client log has been loaded:
152		_onClientLogLoaded: function() {
153			//console.info('BotMon.live.data._onClientLogLoaded()');
154
155			// chain the ticks file to load:
156			BotMon.live.data.loadLogFile('tck', BotMon.live.data._onTicksLogLoaded);
157
158		},
159
160		// event callback, after the tiker log has been loaded:
161		_onTicksLogLoaded: function() {
162			//console.info('BotMon.live.data._onTicksLogLoaded()');
163
164			// analyse the data:
165			BotMon.live.data.analytics.analyseAll();
166
167			// sort the data:
168			// #TODO
169
170			// display the data:
171			BotMon.live.gui.overview.make();
172
173			//console.log(BotMon.live.data.model._visitors);
174
175		},
176
177		model: {
178			// visitors storage:
179			_visitors: [],
180
181			// find an already existing visitor record:
182			findVisitor: function(id) {
183
184				// shortcut to make code more readable:
185				const model = BotMon.live.data.model;
186
187				// loop over all visitors already registered:
188				for (let i=0; i<model._visitors.length; i++) {
189					const v = model._visitors[i];
190					if (v && v.id == id) return v;
191				}
192				return null; // nothing found
193			},
194
195			/* if there is already this visit registered, return it (used for updates) */
196			_getVisit: function(visit, view) {
197
198				// shortcut to make code more readable:
199				const model = BotMon.live.data.model;
200
201
202				for (let i=0; i<visit._pageViews.length; i++) {
203					const pv = visit._pageViews[i];
204					if (pv.pg == view.pg && // same page id, and
205						view.ts.getTime() - pv._firstSeen.getTime() < 1200000) { // seen less than 20 minutes ago
206							return pv; // it is the same visit.
207					}
208				}
209				return null; // not found
210			},
211
212			// register a new visitor (or update if already exists)
213			registerVisit: function(dat, type) {
214				console.info('registerVisit', dat);
215
216				// shortcut to make code more readable:
217				const model = BotMon.live.data.model;
218
219				// is it a known bot?
220				const bot = BotMon.live.data.bots.match(dat.agent);
221
222				// which user id to use:
223				let visitorId = dat.id; // default is the session ID
224				if (bot) visitorId = bot.id; // use bot ID if known bot
225				if (dat.usr !== '') visitorId = 'usr'; // use user ID if known user
226
227				// check if it already exists:
228				let visitor = model.findVisitor(dat.id);
229				if (!visitor) {
230
231					// override the visitor type?
232					let visitorType = dat.typ;
233					if (bot) visitorType = 'bot';
234
235					model._visitors.push(dat);
236					visitor = dat;
237					visitor.id = visitorId;
238					visitor._firstSeen = dat.ts;
239					visitor._lastSeen = dat.ts;
240					visitor._seenBy = [type];
241					visitor._pageViews = []; // array of page views
242					visitor._hasReferrer = false; // has at least one referrer
243					visitor._jsClient = false; // visitor has been seen logged by client js as well
244					visitor._client = BotMon.live.data.clients.match(dat.agent) ?? null; // client info
245					visitor._bot = bot ?? null; // bot info
246					visitor._platform = BotMon.live.data.platforms.match(dat.agent); // platform info
247					visitor._type = visitorType;
248				}
249
250				// find browser
251
252				// is this visit already registered?
253				let prereg = model._getVisit(visitor, dat);
254				if (!prereg) {
255					// add the page view to the visitor:
256					prereg = {
257						_by: 'srv',
258						ip: dat.ip,
259						pg: dat.pg,
260						ref: dat.ref || '',
261						_firstSeen: dat.ts,
262						_lastSeen: dat.ts,
263						_jsClient: false
264					};
265					visitor._pageViews.push(prereg);
266				}
267
268				// update referrer state:
269				visitor._hasReferrer = visitor._hasReferrer ||
270					(prereg.ref !== undefined && prereg.ref !== '');
271
272				// update time stamp for last-seen:
273				visitor._lastSeen = dat.ts;
274
275				// if needed:
276				return visitor;
277			},
278
279			// updating visit data from the client-side log:
280			updateVisit: function(dat) {
281				//console.info('updateVisit', dat);
282
283				// shortcut to make code more readable:
284				const model = BotMon.live.data.model;
285
286				const type = 'log';
287
288				let visitor = BotMon.live.data.model.findVisitor(dat.id);
289				if (!visitor) {
290					visitor = model.registerVisit(dat, type);
291					visitor._seenBy = [type];
292				}
293				if (visitor) {
294
295					// prime the "seen by" list:
296					seenBy = ( visitor._seenBy ? ( visitor._seenBy.includes(type) ? visitor._seenBy : [...visitor._seenBy, type] ) : [type] );
297
298					visitor._lastSeen = dat.ts;
299					visitor._seenBy = seenBy;
300					visitor._jsClient = true; // seen by client js
301				}
302
303				// find the page view:
304				let prereg = BotMon.live.data.model._getVisit(visitor, dat);
305				if (prereg) {
306					// update the page view:
307					prereg._lastSeen = dat.ts;
308					prereg._jsClient = true; // seen by client js
309				} else {
310					// add the page view to the visitor:
311					prereg = {
312						_by: 'log',
313						ip: dat.ip,
314						pg: dat.pg,
315						ref: dat.ref || '',
316						_firstSeen: dat.ts,
317						_lastSeen: dat.ts,
318						_jsClient: true
319					};
320					visitor._pageViews.push(prereg);
321				}
322			},
323
324			// updating visit data from the ticker log:
325			updateTicks: function(dat) {
326				//console.info('updateTicks', dat);
327
328				// shortcut to make code more readable:
329				const model = BotMon.live.data.model;
330
331				// find the visit info:
332				let visitor = model.findVisitor(dat.id);
333				if (!visitor) {
334					console.warn(`No visitor with ID ${dat.id}, registering a new one.`);
335					visitor = model.registerVisit(dat, 'tck');
336				}
337				if (visitor) {
338					// update visitor:
339					if (visitor._lastSeen < dat.ts) visitor._lastSeen = dat.ts;
340					if (!visitor._seenBy.includes('tck')) visitor._seenBy.push('tck');
341
342					// get the page view info:
343					const pv = model._getVisit(visitor, dat);
344					if (pv) {
345						// update the page view info:
346						if (pv._lastSeen.getTime() < dat.ts.getTime()) pv._lastSeen = dat.ts;
347					} else {
348						console.warn(`No page view for visit ID ${dat.id}, page ${dat.pg}, registering a new one.`);
349
350						// add a new page view to the visitor:
351						const newPv = {
352							_by: 'tck',
353							ip: dat.ip,
354							pg: dat.pg,
355							ref: '',
356							_firstSeen: dat.ts,
357							_lastSeen: dat.ts,
358							_jsClient: false
359						};
360						visitor._pageViews.push(newPv);
361					}
362
363				} else {
364					console.warn(`No visit with ID ${dat.id}.`);
365					return;
366				}
367
368			}
369		},
370
371		analytics: {
372
373			init: function() {
374				console.info('BotMon.live.data.analytics.init()');
375			},
376
377			// data storage:
378			data: {
379				totalVisits: 0,
380				totalPageViews: 0,
381				bots: {
382					known: 0,
383					suspected: 0,
384					human: 0,
385					users: 0
386				}
387			},
388
389			// sort the visits by type:
390			groups: {
391				knownBots: [],
392				suspectedBots: [],
393				humans: [],
394				users: []
395			},
396
397			// all analytics
398			analyseAll: function() {
399				//console.info('BotMon.live.data.analytics.analyseAll()');
400
401				// shortcut to make code more readable:
402				const model = BotMon.live.data.model;
403				console.log(model._visitors);
404
405				// loop over all visitors:
406				model._visitors.forEach( (v) => {
407
408					// count visits and page views:
409					this.data.totalVisits += 1;
410					this.data.totalPageViews += v._pageViews.length;
411
412					// check for typical bot aspects:
413					let botScore = 0;
414
415					/*if (v._isBot >= 1.0) { // known bots
416
417						this.data.bots.known += 1;
418						this.groups.knownBots.push(v);
419
420					} if (v.usr && v.usr != '') { // known users */
421						this.groups.users.push(v);
422						this.data.bots.users += 1;
423					/*} else {
424						// not a known bot, nor a known user; check other aspects:
425
426						// no referrer at all:
427						if (!v._hasReferrer) botScore += 0.2;
428
429						// no js client logging:
430						if (!v._jsClient) botScore += 0.2;
431
432						// average time between page views less than 30s:
433						if (v._pageViews.length > 1) {
434							botScore -= 0.2; // more than one view: good!
435							let totalDiff = 0;
436							for (let i=1; i<v._pageViews.length; i++) {
437								const diff = v._pageViews[i]._firstSeen.getTime() - v._pageViews[i-1]._lastSeen.getTime();
438								totalDiff += diff;
439							}
440							const avgDiff = totalDiff / (v._pageViews.length - 1);
441							if (avgDiff < 30000) botScore += 0.2;
442							else if (avgDiff < 60000) botScore += 0.1;
443						}
444
445						// decide based on the score:
446						if (botScore >= 0.5) {
447							this.data.bots.suspected += 1;
448							this.groups.suspectedBots.push(v);
449						} else {
450							this.data.bots.human += 1;
451							this.groups.humans.push(v);
452						}
453					}*/
454				});
455
456				console.log(this.data);
457				console.log(this.groups);
458			}
459
460		},
461
462		bots: {
463			// loads the list of known bots from a JSON file:
464			init: async function() {
465				//console.info('BotMon.live.data.bots.init()');
466
467				// Load the list of known bots:
468				BotMon.live.gui.status.showBusy("Loading known bots …");
469				const url = BotMon._baseDir + 'data/known-bots.json';
470				try {
471					const response = await fetch(url);
472					if (!response.ok) {
473						throw new Error(`${response.status} ${response.statusText}`);
474					}
475
476					this._list = await response.json();
477					this._ready = true;
478
479				} catch (error) {
480					BotMon.live.gui.status.setError("Error while loading the ‘known bots’ file: " + error.message);
481				} finally {
482					BotMon.live.gui.status.hideBusy("Status: Done.");
483					BotMon.live.data._dispatch('bots')
484				}
485			},
486
487			// returns bot info if the clientId matches a known bot, null otherwise:
488			match: function(agent) {
489				//console.info('BotMon.live.data.bots.match(',agent,')');
490
491				const BotList = BotMon.live.data.bots._list;
492
493				// default is: not found!
494				let botInfo = null;
495
496				// check for known bots:
497				if (agent) {
498					BotList.find(bot => {
499						let r = false;
500						for (let j=0; j<bot.rx.length; j++) {
501							const rxr = agent.match(new RegExp(bot.rx[j]));
502							if (rxr) {
503								botInfo = {
504									n : bot.n,
505									id: bot.id,
506									url: bot.url,
507									v: (rxr.length > 1 ? rxr[1] : -1)
508								}
509								r = true;
510								break;
511							}
512						}
513						return r;
514					});
515				}
516
517				//console.log("botInfo:", botInfo);
518				return botInfo;
519			},
520
521
522			// indicates if the list is loaded and ready to use:
523			_ready: false,
524
525			// the actual bot list is stored here:
526			_list: []
527		},
528
529		clients: {
530			// loads the list of known clients from a JSON file:
531			init: async function() {
532				//console.info('BotMon.live.data.clients.init()');
533
534				// Load the list of known bots:
535				BotMon.live.gui.status.showBusy("Loading known clients");
536				const url = BotMon._baseDir + 'data/known-clients.json';
537				try {
538					const response = await fetch(url);
539					if (!response.ok) {
540						throw new Error(`${response.status} ${response.statusText}`);
541					}
542
543					BotMon.live.data.clients._list = await response.json();
544					BotMon.live.data.clients._ready = true;
545
546				} catch (error) {
547					BotMon.live.gui.status.setError("Error while loading the known clients file: " + error.message);
548				} finally {
549					BotMon.live.gui.status.hideBusy("Status: Done.");
550					BotMon.live.data._dispatch('clients')
551				}
552			},
553
554			// returns bot info if the user-agent matches a known bot, null otherwise:
555			match: function(agent) {
556				//console.info('BotMon.live.data.clients.match(',agent,')');
557
558				let match = {"n": "Unknown", "v": -1, "id": null};
559
560				if (agent) {
561					BotMon.live.data.clients._list.find(client => {
562						let r = false;
563						for (let j=0; j<client.rx.length; j++) {
564							const rxr = agent.match(new RegExp(client.rx[j]));
565							if (rxr) {
566								match.n = client.n;
567								match.v = (rxr.length > 1 ? rxr[1] : -1);
568								match.id = client.id || null;
569								r = true;
570								break;
571							}
572						}
573						return r;
574					});
575				}
576
577				//console.log(match)
578				return match;
579			},
580
581			// indicates if the list is loaded and ready to use:
582			_ready: false,
583
584			// the actual bot list is stored here:
585			_list: []
586
587		},
588
589		platforms: {
590			// loads the list of known platforms from a JSON file:
591			init: async function() {
592				//console.info('BotMon.live.data.platforms.init()');
593
594				// Load the list of known bots:
595				BotMon.live.gui.status.showBusy("Loading known platforms");
596				const url = BotMon._baseDir + 'data/known-platforms.json';
597				try {
598					const response = await fetch(url);
599					if (!response.ok) {
600						throw new Error(`${response.status} ${response.statusText}`);
601					}
602
603					BotMon.live.data.platforms._list = await response.json();
604					BotMon.live.data.platforms._ready = true;
605
606				} catch (error) {
607					BotMon.live.gui.status.setError("Error while loading the known platforms file: " + error.message);
608				} finally {
609					BotMon.live.gui.status.hideBusy("Status: Done.");
610					BotMon.live.data._dispatch('platforms')
611				}
612			},
613
614			// returns bot info if the browser id matches a known platform:
615			match: function(cid) {
616				//console.info('BotMon.live.data.platforms.match(',cid,')');
617
618				let match = {"n": "Unknown", "id": null};
619
620				if (cid) {
621					BotMon.live.data.platforms._list.find(platform => {
622						let r = false;
623						for (let j=0; j<platform.rx.length; j++) {
624							const rxr = cid.match(new RegExp(platform.rx[j]));
625							if (rxr) {
626								match.n = platform.n;
627								match.v = (rxr.length > 1 ? rxr[1] : -1);
628								match.id = platform.id || null;
629								r = true;
630								break;
631							}
632						}
633						return r;
634					});
635				}
636
637				return match;
638			},
639
640			// indicates if the list is loaded and ready to use:
641			_ready: false,
642
643			// the actual bot list is stored here:
644			_list: []
645
646		},
647
648		loadLogFile: async function(type, onLoaded = undefined) {
649			console.info('BotMon.live.data.loadLogFile(',type,')');
650
651			let typeName = '';
652			let columns = [];
653
654			switch (type) {
655				case "srv":
656					typeName = "Server";
657					columns = ['ts','ip','pg','id','typ','usr','agent','ref'];
658					break;
659				case "log":
660					typeName = "Page load";
661					columns = ['ts','ip','pg','id','usr','lt','ref','agent'];
662					break;
663				case "tck":
664					typeName = "Ticker";
665					columns = ['ts','ip','pg','id','agent'];
666					break;
667				default:
668					console.warn(`Unknown log type ${type}.`);
669					return;
670			}
671
672			// Show the busy indicator and set the visible status:
673			BotMon.live.gui.status.showBusy(`Loading ${typeName} log file …`);
674
675			// compose the URL from which to load:
676			const url = BotMon._baseDir + `logs/${BotMon._today}.${type}.txt`;
677			//console.log("Loading:",url);
678
679			// fetch the data:
680			try {
681				const response = await fetch(url);
682				if (!response.ok) {
683					throw new Error(`${response.status} ${response.statusText}`);
684				}
685
686				const logtxt = await response.text();
687
688				logtxt.split('\n').forEach((line) => {
689					if (line.trim() === '') return; // skip empty lines
690					const cols = line.split('\t');
691
692					// assign the columns to an object:
693					const data = {};
694					cols.forEach( (colVal,i) => {
695						colName = columns[i] || `col${i}`;
696						const colValue = (colName == 'ts' ? new Date(colVal) : colVal.trim());
697						data[colName] = colValue;
698					});
699
700					// register the visit in the model:
701					switch(type) {
702						case 'srv':
703							BotMon.live.data.model.registerVisit(data, type);
704							break;
705						case 'log':
706							data.typ = 'js';
707							BotMon.live.data.model.updateVisit(data);
708							break;
709						case 'tck':
710							data.typ = 'js';
711							BotMon.live.data.model.updateTicks(data);
712							break;
713						default:
714							console.warn(`Unknown log type ${type}.`);
715							return;
716					}
717				});
718
719				if (onLoaded) {
720					onLoaded(); // callback after loading is finished.
721				}
722
723			} catch (error) {
724				BotMon.live.gui.status.setError(`Error while loading the ${typeName} log file: ${error.message}.`);
725			} finally {
726				BotMon.live.gui.status.hideBusy("Status: Done.");
727			}
728		}
729	},
730
731	gui: {
732		init: function() {
733			// init the lists view:
734			this.lists.init();
735		},
736
737		overview: {
738			make: function() {
739				const data = BotMon.live.data.analytics.data;
740				const parent = document.getElementById('botmon__today__content');
741				if (parent) {
742
743					const bounceRate = Math.round(data.totalVisits / data.totalPageViews * 1000) / 10;
744
745					jQuery(parent).prepend(jQuery(`
746						<details id="botmon__today__overview" open>
747							<summary>Overview</summary>
748							<div class="grid-3-columns">
749								<dl>
750									<dt>Web metrics</dt>
751									<dd><span>Total visits:</span><span>${data.totalVisits}</span></dd>
752									<dd><span>Total page views:</span><span>${data.totalPageViews}</span></dd>
753									<dd><span>Bounce rate:</span><span>${bounceRate}&#x202F;%</span></dd>
754									<dd><span>∅ load time:</span><span>${data.avgLoadTime}&#x202F;ms</span></dd>
755								</dl>
756								<dl>
757									<dt>Bots vs. Humans</dt>
758									<dd><span>Known bots:</span><span>${data.bots.known}</span></dd>
759									<dd><span>Suspected bots:</span><span>${data.bots.suspected}</span></dd>
760									<dd><span>Probably humans:</span><span>${data.bots.human}</span></dd>
761									<dd><span>Registered users:</span><span>${data.bots.users}</span></dd>
762								</dl>
763								<dl id="botmon__botslist">
764									<dt>Known bots</dt>
765								</dl>
766							</div>
767						</details>
768					`));
769				}
770			}
771		},
772		status: {
773			setText: function(txt) {
774				const el = document.getElementById('botmon__today__status');
775				if (el && BotMon.live.gui.status._errorCount <= 0) {
776					el.innerText = txt;
777				}
778			},
779
780			setTitle: function(html) {
781				const el = document.getElementById('botmon__today__title');
782				if (el) {
783					el.innerHTML = html;
784				}
785			},
786
787			setError: function(txt) {
788				console.error(txt);
789				BotMon.live.gui.status._errorCount += 1;
790				const el = document.getElementById('botmon__today__status');
791				if (el) {
792					el.innerText = "An error occured. See the browser log for details!";
793					el.classList.add('error');
794				}
795			},
796			_errorCount: 0,
797
798			showBusy: function(txt = null) {
799				BotMon.live.gui.status._busyCount += 1;
800				const el = document.getElementById('botmon__today__busy');
801				if (el) {
802					el.style.display = 'inline-block';
803				}
804				if (txt) BotMon.live.gui.status.setText(txt);
805			},
806			_busyCount: 0,
807
808			hideBusy: function(txt = null) {
809				const el = document.getElementById('botmon__today__busy');
810				BotMon.live.gui.status._busyCount -= 1;
811				if (BotMon.live.gui.status._busyCount <= 0) {
812					if (el) el.style.display = 'none';
813					if (txt) BotMon.live.gui.status.setText(txt);
814				}
815			}
816		},
817
818		lists: {
819			init: function() {
820
821				const parent = document.getElementById('botmon__today__visitorlists');
822				if (parent) {
823
824					for (let i=0; i < 4; i++) {
825
826						// change the id and title by number:
827						let listTitle = '';
828						let listId = '';
829						switch (i) {
830							case 0:
831								listTitle = "Registered users";
832								listId = 'users';
833								break;
834							case 1:
835								listTitle = "Probably humans";
836								listId = 'humans';
837								break;
838							case 2:
839								listTitle = "Suspected bots";
840								listId = 'suspectedBots';
841								break;
842							case 3:
843								listTitle = "Known bots";
844								listId = 'knownBots';
845								break;
846							default:
847								console.warn('Unknwon list number.');
848						}
849
850						const details = BotMon.t._makeElement('details', {
851							'data-group': listId,
852							'data-loaded': false
853						});
854						details.appendChild(BotMon.t._makeElement('summary',
855							undefined,
856							listTitle
857						));
858						details.addEventListener("toggle", this._onDetailsToggle);
859
860						parent.appendChild(details);
861
862					}
863				}
864			},
865
866			_onDetailsToggle: function(e) {
867				console.info('BotMon.live.gui.lists._onDetailsToggle()');
868
869				const target = e.target;
870
871				if (target.getAttribute('data-loaded') == 'false') { // only if not loaded yet
872					target.setAttribute('data-loaded', 'loading');
873
874					const fillType = target.getAttribute('data-group');
875					const fillList = BotMon.live.data.analytics.groups[fillType];
876					if (fillList && fillList.length > 0) {
877
878						const ul = BotMon.t._makeElement('ul');
879
880						fillList.forEach( (it) => {
881							ul.appendChild(BotMon.live.gui.lists._makeVisitorItem(it, fillType));
882						});
883
884						target.appendChild(ul);
885						target.setAttribute('data-loaded', 'true');
886					} else {
887						target.setAttribute('data-loaded', 'false');
888					}
889
890				}
891			},
892
893			_makeVisitorItem: function(data, type) {
894
895				// shortcut for neater code:
896				const make = BotMon.t._makeElement;
897
898				let ipType = ( data.ip.indexOf(':') >= 0 ? '6' : '4' );
899
900				const li = make('li'); // root list item
901				const details = make('details');
902				const summary = make('summary');
903				details.appendChild(summary);
904
905				const span1 = make('span'); /* left-hand group */
906
907				if (data._type == 'bot') { /* Bot only */
908
909					span1.appendChild(make('span', { /* Bot */
910						'class': 'bot bot_' + (data._bot ? data._bot.id : 'unknown'),
911						'title': "Bot: " + (data._bot ? data._bot.n : 'Unknown')
912					}, (data._bot ? data._bot.n : 'Unknown')));
913
914				} else if (data._type == 'usr') { /* User only */
915
916					span1.appendChild(make('span', { /* User */
917						'class': 'user' + (data._user ? data._user.id : 'unknown'),
918						'title': "User: " + data.usr
919					}, data.usr));
920
921				} else { /* others */
922
923					if (data.ip == '127.0.0.1' || data.ip == '::1' ) ipType = '0';
924					span1.appendChild(make('span', { /* IP-Address */
925						'class': 'ipaddr ip' + ipType,
926						'title': "IP-Address: " + data.ip
927					}, data.ip));
928
929				}
930
931				const platformName = (data._platform ? data._platform.n : 'Unknown');
932				span1.appendChild(make('span', { /* Platform */
933					'class': 'icon platform platform_' + (data._platform ? data._platform.id : 'unknown'),
934					'title': "Platform: " + platformName
935				}, platformName));
936
937				const clientName = (data._client ? data._client.n: 'Unknown');
938				span1.appendChild(make('span', { /* Client */
939					'class': 'icon client client_' + (data._client ? data._client.id : 'unknown'),
940					'title': "Client: " + clientName
941				}, clientName));
942
943
944
945				summary.appendChild(span1);
946				const span2 = make('span'); /* right-hand group */
947
948				span2.appendChild(make('time', { /* Last seen */
949					'data-field': 'last-seen',
950					'datetime': (data._lastSeen ? data._lastSeen : 'unknown')
951				}, (data._lastSeen ? data._lastSeen.getHours() + ':' + data._lastSeen.getMinutes() + ':' + data._lastSeen.getSeconds() : 'Unknown')));
952
953				summary.appendChild(span2);
954
955				// create expanable section:
956
957				const dl = make('dl', {'class': 'visitor_details'});
958
959				if (data._bot) {
960					dl.appendChild(make('dt', {}, "Bot:")); /* bot info */
961					dl.appendChild(make('dd', {'class': 'has_icon bot bot_' + (data._bot ? data._bot.id : 'unknown')},
962						(data._bot ? data._bot.n : 'Unknown')));
963				}
964
965				dl.appendChild(make('dt', {}, "Client:")); /* client */
966				dl.appendChild(make('dd', {'class': 'has_icon client_' + (data._client ? data._client.id : 'unknown')},
967					clientName + ( data._client.v > 0 ? ' (' + data._client.v + ')' : '' ) ));
968
969				dl.appendChild(make('dt', {}, "Platform:")); /* platform */
970				dl.appendChild(make('dd', {'class': 'has_icon platform_' + (data._platform ? data._platform.id : 'unknown')},
971					platformName + ( data._platform.v > 0 ? ' (' + data._platform.v + ')' : '' ) ));
972
973				dl.appendChild(make('dt', {}, "IP-Address:"));
974				dl.appendChild(make('dd', {'class': 'has_icon ip' + ipType}, data.ip));
975
976				if ((data._lastSeen - data._firstSeen) < 1) {
977					dl.appendChild(make('dt', {}, "Seen:"));
978					dl.appendChild(make('dd', {'class': 'seen'}, data._firstSeen.toLocaleString()));
979				} else {
980					dl.appendChild(make('dt', {}, "First seen:"));
981					dl.appendChild(make('dd', {'class': 'firstSeen'}, data._firstSeen.toLocaleString()));
982					dl.appendChild(make('dt', {}, "Last seen:"));
983					dl.appendChild(make('dd', {'class': 'lastSeen'}, data._lastSeen.toLocaleString()));
984				}
985
986				dl.appendChild(make('dt', {}, "User-Agent:"));
987				dl.appendChild(make('dd', {'class': 'agent' + ipType}, data.agent));
988
989				dl.appendChild(make('dt', {}, "Visited pages:"));
990				const pagesDd = make('dd', {'class': 'pages'});
991				const pageList = make('ul');
992
993				data._pageViews.forEach( (page) => {
994					const pgLi = make('li');
995
996					let visitTimeStr = "Bounce";
997					const visitDuration = page._lastSeen.getTime() - page._firstSeen.getTime();
998					if (visitDuration > 0) {
999						visitTimeStr = Math.floor(visitDuration / 1000) + "s";
1000					}
1001
1002					pgLi.appendChild(make('span', {}, page.pg));
1003					pgLi.appendChild(make('span', {}, page.ref));
1004					pgLi.appendChild(make('span', {}, visitTimeStr));
1005					pageList.appendChild(pgLi);
1006				});
1007
1008				pagesDd.appendChild(pageList);
1009				dl.appendChild(pagesDd);
1010
1011				details.appendChild(dl);
1012
1013				li.appendChild(details);
1014				return li;
1015			}
1016		}
1017	}
1018};
1019
1020/* launch only if the BotMon admin panel is open: */
1021if (document.getElementById('botmon__admin')) {
1022	BotMon.init();
1023}