xref: /plugin/botmon/script.js (revision 9f1ee8c1f3ed9963111a116a92ad1baea9857250)
1/* DokuWiki Monitor Plugin Script file */
2/* 30.08.2025 - 0.1.5 - pre-release */
3/* Authors: Sascha Leib <ad@hominem.info> */
4
5const Monitor = {
6
7	init: function() {
8		//console.info('Monitor.init()');
9
10		// find the plugin basedir:
11		this._baseDir = document.currentScript.src.substring(0, document.currentScript.src.indexOf('/exe/'))
12			+ '/plugins/monitor/';
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 = Monitor.t._getTimeOffset();
19
20		// init the sub-objects:
21		Monitor.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('Monitor.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};
68
69/* everything specific to the "Today" tab is self-contained in the "live" object: */
70Monitor.live = {
71	init: function() {
72		//console.info('Monitor.live.init()');
73
74		// set the title:
75		const tDiff = '(<abbr title="Coordinated Universal Time">UTC</abbr>' + (Monitor._timeDiff != '' ? `, ${Monitor._timeDiff}` : '' ) + ')';
76		Monitor.live.status.setTitle(`Showing data for <time datetime=${Monitor._today}>${Monitor._today}</time> ${tDiff}`);
77
78		// init sub-objects:
79		Monitor.t._callInit(this);
80	},
81
82	data: {
83		init: function() {
84			//console.info('Monitor.live.data.init()');
85
86			// call sub-inits:
87			Monitor.t._callInit(this);
88		},
89
90		// this will be called when the known json files are done loading:
91		_dispatch: function(file) {
92			//console.info('Monitor.live.data._dispatch(,',file,')');
93
94			// shortcut to make code more readable:
95			const data = Monitor.live.data;
96
97			// set the flags:
98			switch(file) {
99				case 'bots':
100					data._dispatchBotsLoaded = true;
101					break;
102				case 'clients':
103					data._dispatchClientsLoaded = true;
104					break;
105				case 'platforms':
106					data._dispatchPlatformsLoaded = true;
107					break;
108				default:
109					// ignore
110			}
111
112			// are all the flags set?
113			if (data._dispatchBotsLoaded && data._dispatchClientsLoaded && data._dispatchPlatformsLoaded) {
114				// chain the log files loading:
115				Monitor.live.data.loadLogFile('srv', Monitor.live.data._onServerLogLoaded);
116			}
117		},
118		// flags to track which data files have been loaded:
119		_dispatchBotsLoaded: false,
120		_dispatchClientsLoaded: false,
121		_dispatchPlatformsLoaded: false,
122
123		// event callback, after the server log has been loaded:
124		_onServerLogLoaded: function() {
125			//console.info('Monitor.live.data._onServerLogLoaded()');
126
127			// chain the client log file to load:
128			Monitor.live.data.loadLogFile('log', Monitor.live.data._onClientLogLoaded);
129		},
130
131		// event callback, after the client log has been loaded:
132		_onClientLogLoaded: function() {
133			console.info('Monitor.live.data._onClientLogLoaded()');
134
135			// chain the ticks file to load:
136			// Monitor.live.data.loadLogFile('tck', Monitor.live.data._onTicksLogLoaded);
137			//Monitor.live.data.loadLogFile('tck', Monitor.live.data._onTicksLogLoaded);
138
139			console.log(Monitor.live.data.model._visitors);
140			//Monitor.live.data._onTicksLogLoaded();
141		},
142
143		// event callback, after the tiker log has been loaded:
144		_onTicksLogLoaded: function() {
145			console.info('Monitor.live.data._onTicksLogLoaded()');
146
147			// analyse the data:
148			// #TODO
149
150			// sort the data:
151			// #TODO
152
153			// display the data:
154			// #TODO
155
156			console.log(Monitor.live.data.model._visitors);
157
158		},
159
160		model: {
161			// visitors storage:
162			_visitors: [],
163
164			// find an already existing visitor record:
165			findVisitor: function(id) {
166
167				// shortcut to make code more readable:
168				const model = Monitor.live.data.model;
169
170				// loop over all visitors already registered:
171				for (let i=0; i<model._visitors.length; i++) {
172					const v = model._visitors[i];
173					if (v && v.id == id) return v;
174				}
175				return null; // nothing found
176			},
177
178			/* if there is already this visit registered, return it (used for updates) */
179			_getVisit: function(visit, view) {
180
181				for (let i=0; i<visit._pageViews.length; i++) {
182					const v = visit._pageViews[i];
183					if (v.pg == view.pg && // same page id, and
184						view.ts.getTime() - v._firstSeen.getTime() < 600000) { // seen less than 10 minutes ago
185							return v; // it is the same visit.
186					}
187				}
188				return null; // not found
189			},
190
191			// register a new visitor (or update if already exists)
192			registerVisit: function(dat) {
193				//console.info('registerVisit', dat);
194
195				// shortcut to make code more readable:
196				const model = Monitor.live.data.model;
197
198				// check if it already exists:
199				let visitor = model.findVisitor(dat.id);
200				if (!visitor) {
201					const bot = Monitor.live.data.bots.match(dat.client);
202
203					model._visitors.push(dat);
204					visitor = dat;
205					visitor._firstSeen = dat.ts;
206					visitor._lastSeen = dat.ts;
207					visitor._isBot = ( bot ? 1.0 : 0.0 ); // likelihood of being a bot; primed to 0% or 100% in case of a known bot
208					visitor._pageViews = []; // array of page views
209					visitor._hasReferrer = false; // has at least one referrer
210					visitor._jsClient = false; // visitor has been seen logged by client js as well
211					visitor._client = bot ?? Monitor.live.data.clients.match(dat.client) ?? null; // client info (browser, bot, etc.)
212					visitor._platform = Monitor.live.data.platforms.match(dat.client); // platform info
213
214					// known bots get the bot ID as identifier:
215					if (bot) visitor.id = bot.id;
216				}
217
218				// find browser
219
220				// is this visit already registered?
221				let prereg = model._getVisit(visitor, dat);
222				if (!prereg) {
223					// add the page view to the visitor:
224					prereg = {
225						_by: 'srv',
226						ip: dat.ip,
227						pg: dat.pg,
228						ref: dat.ref || '',
229						_firstSeen: dat.ts,
230						_lastSeen: dat.ts,
231						_jsClient: false
232					};
233					visitor._pageViews.push(prereg);
234				}
235
236				// update referrer state:
237				visitor._hasReferrer = visitor._hasReferrer ||
238					(prereg.ref !== undefined && prereg.ref !== '');
239
240				// update time stamp for last-seen:
241				visitor._lastSeen = dat.ts;
242
243				// if needed:
244				return visitor;
245			},
246
247			// updating visit data from the client-side log:
248			updateVisit: function(dat) {
249				//console.info('updateVisit', dat);
250
251				// shortcut to make code more readable:
252				const model = Monitor.live.data.model;
253
254				let visitor = model.findVisitor(dat.id);
255				if (!visitor) {
256					visitor = model.registerVisit(dat);
257				}
258				if (visitor) {
259					visitor._lastSeen = dat.ts;
260					visitor._jsClient = true; // seen by client js
261else {
262					console.warn(`No visit with ID ${dat.id}.`);
263					return;
264				}
265
266				// find the page view:
267				let prereg = model._getVisit(visitor, dat);
268				if (prereg) {
269					// update the page view:
270					prereg._lastSeen = dat.ts;
271					prereg._jsClient = true; // seen by client js
272				} else {
273					// add the page view to the visitor:
274					prereg = {
275						_by: 'log',
276						ip: dat.ip,
277						pg: dat.pg,
278						ref: dat.ref || '',
279						_firstSeen: dat.ts,
280						_lastSeen: dat.ts,
281						_jsClient: true
282					};
283					visitor._pageViews.push(prereg);
284				}
285			},
286
287			// updating visit data from the ticker log:
288			updateTicks: function(dat) {
289				console.info('updateTicks', dat);
290
291				// shortcut to make code more readable:
292				const model = Monitor.live.data.model;
293
294				// find the visit info:
295				let visitor = model.findVisitor(dat.id);
296				if (visitor) {
297					console.log("Visitor:", visitor);
298				} else {
299					//model.registerVisit(dat);
300					console.warn(`Unknown visitor with ID ${dat.id}!`);
301				}
302
303			}
304		},
305
306		bots: {
307			// loads the list of known bots from a JSON file:
308			init: async function() {
309				//console.info('Monitor.live.data.bots.init()');
310
311				// Load the list of known bots:
312				Monitor.live.status.showBusy("Loading known bots …");
313				const url = Monitor._baseDir + 'data/known-bots.json';
314				try {
315					const response = await fetch(url);
316					if (!response.ok) {
317						throw new Error(`${response.status} ${response.statusText}`);
318					}
319
320					Monitor.live.data.bots._list = await response.json();
321					Monitor.live.data.bots._ready = true;
322
323					// TODO: allow using the bots list...
324				} catch (error) {
325					Monitor.live.status.setError("Error while loading the ’known bots’ file: " + error.message);
326				} finally {
327					Monitor.live.status.hideBusy("Done.");
328					Monitor.live.data._dispatch('bots')
329				}
330			},
331
332			// returns bot info if the clientId matches a known bot, null otherwise:
333			match: function(client) {
334				//console.info('Monitor.live.data.bots.match(',client,')');
335
336				if (client) {
337					for (let i=0; i<Monitor.live.data.bots._list.length; i++) {
338						const bot = Monitor.live.data.bots._list[i];
339						for (let j=0; j<bot.rx.length; j++) {
340							if (client.match(new RegExp(bot.rx[j]))) {
341								return bot; // found a match
342							}
343						}
344						return null; // not found!
345					}
346				}
347			},
348
349			// indicates if the list is loaded and ready to use:
350			_ready: false,
351
352			// the actual bot list is stored here:
353			_list: []
354		},
355
356		clients: {
357			// loads the list of known clients from a JSON file:
358			init: async function() {
359				//console.info('Monitor.live.data.clients.init()');
360
361				// Load the list of known bots:
362				Monitor.live.status.showBusy("Loading known clients");
363				const url = Monitor._baseDir + 'data/known-clients.json';
364				try {
365					const response = await fetch(url);
366					if (!response.ok) {
367						throw new Error(`${response.status} ${response.statusText}`);
368					}
369
370					Monitor.live.data.clients._list = await response.json();
371					Monitor.live.data.clients._ready = true;
372
373				} catch (error) {
374					Monitor.live.status.setError("Error while loading the known clients file: " + error.message);
375				} finally {
376					Monitor.live.status.hideBusy("Done.");
377					Monitor.live.data._dispatch('clients')
378				}
379			},
380
381			// returns bot info if the clientId matches a known bot, null otherwise:
382			match: function(cid) {
383				//console.info('Monitor.live.data.clients.match(',cid,')');
384
385				let match = {"n": "Unknown", "v": -1, "id": null};
386
387				if (cid) {
388					Monitor.live.data.clients._list.find(client => {
389						let r = false;
390						for (let j=0; j<client.rx.length; j++) {
391							const rxr = cid.match(new RegExp(client.rx[j]));
392							if (rxr) {
393								match.n = client.n;
394								match.v = (rxr.length > 1 ? rxr[1] : -1);
395								match.id = client.id || null;
396								r = true;
397								break;
398							}
399						}
400						return r;
401					});
402				}
403
404				return match;
405			},
406
407			// indicates if the list is loaded and ready to use:
408			_ready: false,
409
410			// the actual bot list is stored here:
411			_list: []
412
413		},
414
415		platforms: {
416			// loads the list of known platforms from a JSON file:
417			init: async function() {
418				//console.info('Monitor.live.data.platforms.init()');
419
420				// Load the list of known bots:
421				Monitor.live.status.showBusy("Loading known platforms");
422				const url = Monitor._baseDir + 'data/known-platforms.json';
423				try {
424					const response = await fetch(url);
425					if (!response.ok) {
426						throw new Error(`${response.status} ${response.statusText}`);
427					}
428
429					Monitor.live.data.platforms._list = await response.json();
430					Monitor.live.data.platforms._ready = true;
431
432				} catch (error) {
433					Monitor.live.status.setError("Error while loading the known platforms file: " + error.message);
434				} finally {
435					Monitor.live.status.hideBusy("Done.");
436					Monitor.live.data._dispatch('platforms')
437				}
438			},
439
440			// returns bot info if the browser id matches a known platform:
441			match: function(cid) {
442				//console.info('Monitor.live.data.platforms.match(',cid,')');
443
444				let match = {"n": "Unknown", "id": null};
445
446				if (cid) {
447					Monitor.live.data.platforms._list.find(platform => {
448						let r = false;
449						for (let j=0; j<platform.rx.length; j++) {
450							const rxr = cid.match(new RegExp(platform.rx[j]));
451							if (rxr) {
452								match.n = platform.n;
453								match.v = (rxr.length > 1 ? rxr[1] : -1);
454								match.id = platform.id || null;
455								r = true;
456								break;
457							}
458						}
459						return r;
460					});
461				}
462
463				return match;
464			},
465
466			// indicates if the list is loaded and ready to use:
467			_ready: false,
468
469			// the actual bot list is stored here:
470			_list: []
471
472		},
473
474		loadLogFile: async function(type, onLoaded = undefined) {
475			// console.info('Monitor.live.data.loadLogFile(',type,')');
476
477			let typeName = '';
478			let columns = [];
479
480			switch (type) {
481				case "srv":
482					typeName = "Server";
483					columns = ['ts','ip','pg','id','typ','usr','client','ref'];
484					break;
485				case "log":
486					typeName = "Page load";
487					columns = ['ts','ip','pg','id','usr','lt','ref','client'];
488					break;
489				case "tck":
490					typeName = "Ticker";
491					columns = ['ts','ip','pg','id'];
492					break;
493				default:
494					console.warn(`Unknown log type ${type}.`);
495					return;
496			}
497
498			// Show the busy indicator and set the visible status:
499			Monitor.live.status.showBusy(`Loading ${typeName} log file …`);
500
501			// compose the URL from which to load:
502			const url = Monitor._baseDir + `logs/${Monitor._today}.${type}`;
503			//console.log("Loading:",url);
504
505			// fetch the data:
506			try {
507				const response = await fetch(url);
508				if (!response.ok) {
509					throw new Error(`${response.status} ${response.statusText}`);
510				}
511
512				const logtxt = await response.text();
513
514				logtxt.split('\n').forEach((line) => {
515					if (line.trim() === '') return; // skip empty lines
516					const cols = line.split('\t');
517
518					// assign the columns to an object:
519					const data = {};
520					cols.forEach( (colVal,i) => {
521						colName = columns[i] || `col${i}`;
522						const colValue = (colName == 'ts' ? new Date(colVal) : colVal);
523						data[colName] = colValue;
524					});
525
526					// register the visit in the model:
527					switch(type) {
528						case 'srv':
529							Monitor.live.data.model.registerVisit(data);
530							break;
531						case 'log':
532							Monitor.live.data.model.updateVisit(data);
533							break;
534						case 'tck':
535							Monitor.live.data.model.updateTicks(data);
536							break;
537						default:
538							console.warn(`Unknown log type ${type}.`);
539							return;
540					}
541				});
542
543				if (onLoaded) {
544					onLoaded(); // callback after loading is finished.
545				}
546
547			} catch (error) {
548				Monitor.live.status.setError(`Error while loading the ${typeName} log file: ${error.message}.`);
549			} finally {
550				Monitor.live.status.hideBusy("Done.");
551			}
552		}
553	},
554
555	status: {
556		setText: function(txt) {
557			const el = document.getElementById('monitor__today__status');
558			if (el && Monitor.live.status._errorCount <= 0) {
559				el.innerText = txt;
560			}
561		},
562
563		setTitle: function(html) {
564			const el = document.getElementById('monitor__today__title');
565			if (el) {
566				el.innerHTML = html;
567			}
568		},
569
570		setError: function(txt) {
571			console.error(txt);
572			Monitor.live.status._errorCount += 1;
573			const el = document.getElementById('monitor__today__status');
574			if (el) {
575				el.innerText = "An error occured. See the browser log for details!";
576				el.classList.add('error');
577			}
578		},
579		_errorCount: 0,
580
581		showBusy: function(txt = null) {
582			Monitor.live.status._busyCount += 1;
583			const el = document.getElementById('monitor__today__busy');
584			if (el) {
585				el.style.display = 'inline-block';
586			}
587			if (txt) Monitor.live.status.setText(txt);
588		},
589		_busyCount: 0,
590
591		hideBusy: function(txt = null) {
592			const el = document.getElementById('monitor__today__busy');
593			Monitor.live.status._busyCount -= 1;
594			if (Monitor.live.status._busyCount <= 0) {
595				if (el) el.style.display = 'none';
596				if (txt) Monitor.live.status.setText(txt);
597			}
598		}
599	}
600};
601
602/* launch only if the Monitor admin panel is open: */
603if (document.getElementById('monitor__admin')) {
604	Monitor.init();
605}