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