summaryrefslogtreecommitdiffstats
path: root/applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js
blob: 5a87c7c7b70d7f9c26ea3bd5881627eef7a5a61f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
'use strict';
'require view';
'require rpc';
'require ui';
'require librespeed.common as lscommon';

/* The alias comes from the require above; the repo eslint config only
 * knows the stock module names. */
/* global lscommon */

/* The chart wants every point in the range, but the table must not put
 * thousands of rows into the DOM: a 15-minute schedule kept for 30 days is
 * nearly 3000 measurements. Paging keeps the page responsive without a
 * second fetch, since the entries are in memory anyway. */
const pageSize = 50;

const callConfig = rpc.declare({
	object: 'librespeed',
	method: 'config',
	expect: { '': {} }
});

const callHistory = rpc.declare({
	object: 'librespeed',
	method: 'history',
	params: [ 'from', 'to', 'limit' ],
	expect: { '': {} }
});

return view.extend({
	entries: [],
	prevEntries: [],
	resolution: 'raw',
	group: 'speed',
	active: null,
	range: 86400,
	server: '',
	iface: '',
	page: 0,

	/* Which entries come back -- and at which resolution -- is the server's
	 * decision, so every range switch is a fetch rather than a client-side
	 * filter over one download. The previous window of the same length is
	 * fetched alongside, so the summary can say how this period compares. */
	fetch() {
		const now = Math.floor(Date.now() / 1000);
		/* Zero, not undefined: the rpcd argument is typed as an integer and a
		 * null would fail validation rather than mean "everything". */
		const from = this.range ? now - this.range : 0;

		return Promise.all([
			callHistory(from).catch(() => ({})),
			this.range
				? callHistory(now - 2 * this.range, now - this.range).catch(() => ({}))
				: Promise.resolve({})
		]).then(L.bind(function(data) {
			this.entries = Array.isArray(data[0].entries) ? data[0].entries : [];
			this.resolution = data[0].resolution || 'raw';
			this.prevEntries = Array.isArray(data[1].entries) ? data[1].entries : [];
			this.prevResolution = data[1].resolution || 'raw';
		}, this));
	},

	load() {
		/* Both series of a group start visible; the legend narrows it. */
		this.active = {
			speed: lscommon.GROUPS.speed.slice(),
			latency: lscommon.GROUPS.latency.slice()
		};
		/* The config rides along for one purpose: with history keeping off,
		 * the empty state must not send the user to run a test that will
		 * never produce an entry. */
		return Promise.all([
			this.fetch(),
			callConfig().then(c => { this.config = c; }, () => {})
		]);
	},

	/* The server/interface filters narrow the fetched range on the client;
	 * daily aggregates carry neither field, so there the filters offer only
	 * "All" and match everything. */
	applyFilters(entries) {
		return (entries || []).filter(e =>
			(!this.server || (e.server && e.server.name) == this.server) &&
			(!this.iface || e.interface == this.iface));
	},

	visible() {
		return this.applyFilters(this.entries);
	},

	filterSelect(title, values, current, onpick) {
		const sel = E('select', { 'class': 'cbi-input-select' },
			[ E('option', { 'value': '' }, [ _('All') ]) ].concat(
				values.map(v => E('option', { 'value': v }, [ v ]))));

		sel.value = values.indexOf(current) >= 0 ? current : '';
		sel.addEventListener('change', ui.createHandlerFn(this, function() {
			return onpick.call(this, sel.value);
		}));

		return E('label', {}, [
			E('span', { 'class': 'librespeed-muted' }, [ title + ' ' ]), sel ]);
	},

	/* The header above the chart doubles as the legend: per series a checkbox
	 * with its color, its average, and the change against the previous period
	 * -- one place to read the numbers and switch the lines, right where the
	 * eye already is. Below it, one sentence on how the period behaved. */
	renderSummary() {
		const entries = this.visible(),
		      prev = this.applyFilters(this.prevEntries),
		      act = this.active[this.group],
		      states = [];

		this.summaryNode.innerHTML = '';

		const row = E('div', { 'class': 'librespeed-cards' });

		lscommon.GROUPS[this.group].forEach(L.bind(function(k, ki) {
			const m = lscommon.METRICS.find(x => x[0] == k),
			      st = lscommon.seriesStats(entries, k, this.resolution),
			      ps = lscommon.seriesStats(prev, k, this.prevResolution),
			      on = act.indexOf(k) >= 0;

			let diff = ' ';

			/* Only comparable windows get compared: an average of raw samples
			 * against an average of daily aggregates is not the same number,
			 * and the two fetches may straddle the raw/archive boundary. */
			if (on && st && ps && ps.avg > 0 && this.prevResolution == this.resolution) {
				const pct = (st.avg - ps.avg) / ps.avg * 100;
				diff = '%s%d %% %s'.format(pct >= 0 ? '+' : '−',
					Math.abs(Math.round(pct)), _('vs previous period'));
			}

			/* The card is a label around a real checkbox, so it works from the
			 * keyboard exactly like the control it is; the dimming and the
			 * accent stripe say what is drawn. */
			const cb = E('input', { 'type': 'checkbox' });

			cb.checked = on;
			cb.addEventListener('change', L.bind(function() {
				if (cb.checked && act.indexOf(k) < 0)
					act.push(k);
				else if (!cb.checked) {
					if (act.length < 2) {
						cb.checked = true;
						return;
					}
					act.splice(act.indexOf(k), 1);
				}
				this.redraw();
			}, this));

			row.appendChild(E('label', {
				'class': 'librespeed-metric-card librespeed-accent-%d'.format(ki) +
					(on ? ' librespeed-metric-card-on' : ''),
				'title': on ? _('Hide this series') : _('Show this series')
			}, [
				/* Not muted: the card's own off-state opacity already dims
				 * it, and two factors multiply into illegibility. */
				E('div', { 'class': 'librespeed-caps' },
					[ cb, ' ', m[1] ]),
				E('div', { 'class': 'librespeed-card-value' }, [
					st ? '%.1f'.format(st.avg) + ' ' + m[2] : '–',
					E('span', { 'class': 'librespeed-muted', 'style': 'font-size:.6em; font-weight:400' },
						[ ' ' + _('avg') ])
				]),
				E('div', { 'class': 'librespeed-muted', 'style': 'font-size:.85em' }, [ diff ])
			]));

			if (on && st) {
				const varPct = (st.max - st.min) / st.avg * 100,
				      offPct = (st.current - st.avg) / st.avg * 100;
				const bad = (k == 'ping_ms' || k == 'jitter_ms') ? offPct > 15 : offPct < -15;

				states.push([ m[1],
					bad ? _('declining') : (varPct > 30 ? _('highly variable') : _('stable')) ]);
			}
		}, this));

		this.summaryNode.appendChild(row);

		if (states.length) {
			const allStable = states.every(s => s[1] == _('stable'));
			this.summaryNode.appendChild(E('p', { 'class': 'librespeed-muted', 'style': 'margin:0 0 .25em' }, [
				allStable
					? _('Stable over the selected period.')
					: states.map(s => '%s: %s'.format(s[0], s[1])).join(' · ')
			]));
		}
	},

	redraw() {
		const entries = this.visible();

		/* Without data the empty state IS the page: everything that only
		 * makes sense with measurements -- cards, chart, table, export --
		 * stays out of the way entirely, and the one useful action is a
		 * button to go run a test. Filters that merely exclude everything
		 * get their own wording and no button: data exists, go widen them. */
		const noneAtAll = !this.entries.length;

		if (!entries.length) {
			/* Strict, matching test.js: the backend emits a real boolean
			 * (derived from UCI, never its raw '0'/'1'), and an absent or
			 * failed config must read as "history on", not off. */
			const histOff = noneAtAll && this.config &&
				this.config.history && this.config.history.enabled === false;

			/* With history keeping off, a test would never write an entry:
			 * the way out is Settings, not the Start button. */
			this.emptyText.textContent = histOff
				? _('History keeping is switched off, so measurements are not recorded.')
				: (noneAtAll
					? _('Run a speed test to start building your connection history.')
					: _('No measurements match the current filters.'));
			this.emptyStart.style.display = noneAtAll ? '' : 'none';
			this.emptyStart.href = histOff
				? L.url('admin', 'network', 'librespeed', 'settings')
				: L.url('admin', 'network', 'librespeed', 'test');
			this.emptyStart.textContent = histOff ? _('Open settings') : _('Start test');
			this.emptyNode.style.display = '';
			this.dataNode.style.display = 'none';
			return;
		}

		this.emptyNode.style.display = 'none';
		this.dataNode.style.display = '';

		this.renderSummary();

		/* The legend lives in the summary header above; the chart itself has
		 * nothing below it that could be mistaken for table furniture. */
		const drew = lscommon.renderChart(this.chartNode, entries, {
			series: this.active[this.group],
			resolution: this.resolution,
			hover: true
		});

		/* Entries exist -- the empty state above handles the case where they
		 * do not -- so reaching here means this metric has no numbers. */
		if (!drew)
			this.chartNode.appendChild(E('p', { 'class': 'librespeed-muted' },
				[ _('No data for the selected metric.') ]));
		else if (this.resolution == '1d')
			this.chartNode.appendChild(E('p', { 'class': 'librespeed-muted' },
				[ _('Daily minimum, average and maximum.') ]));

		/* The sort key carries the same decimal count as the display half:
		 * ui.Table stringifies the whole cell and compares digit runs one by
		 * one, so ragged fractions would put 94.4 above 94.35. */
		const num = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '';
		const fmtNum = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '–';
		const when = e => lscommon.chartStampFull(e, this.resolution);

		/* One column carries both what the packets were (IPv4/IPv6) and how
		 * they travelled (HTTPS/HTTP); either half may be unknown. */
		const protoCell = e => {
			const fam = e.family == 'ipv6' ? 'IPv6' : (e.family == 'ipv4' ? 'IPv4' : null),
			      pr = e.proto ? e.proto.toUpperCase() : null;
			return [ fam, pr ].filter(x => x).join(' · ') || '–';
		};

		/* Build the cells first, then sort them: the sort key comes out of a
		 * cell, and ui.Table only ever orders the rows it was handed, so
		 * paging first would turn the headers into a per-page sort while the
		 * count below still speaks for every measurement. */
		/* Every display half is a node: ui.Table writes bare strings into
		 * innerHTML, and the server name comes from a downloaded list --
		 * text must stay text. The sort key stays the first element. */
		const cell = t => E('span', {}, [ t ]);
		let rows = entries.slice().reverse().map(e => [
			[ e.epoch ?? 0, cell(when(e)) ],
			cell((e.server && e.server.name) || '–'),
			cell(e.interface || '–'),
			cell(protoCell(e)),
			[ num(e.download_mbps, 2), cell(fmtNum(e.download_mbps, 2)) ],
			[ num(e.upload_mbps, 2), cell(fmtNum(e.upload_mbps, 2)) ],
			[ num(e.ping_ms, 1), cell(fmtNum(e.ping_ms, 1)) ],
			[ num(e.jitter_ms, 1), cell(fmtNum(e.jitter_ms, 1)) ]
		]);

		const sorting = this.table.getActiveSortState();

		if (sorting)
			rows = rows
				.map(L.bind(function(r) {
					return [ this.table.deriveSortKey(r[sorting[0]], sorting[0]), r ];
				}, this))
				.sort((a, b) => sorting[1]
					? -L.naturalCompare(a[0], b[0])
					: L.naturalCompare(a[0], b[0]))
				.map(x => x[1]);

		const pages = Math.max(1, Math.ceil(rows.length / pageSize));

		if (this.page >= pages)
			this.page = pages - 1;

		this.renderPager(rows.length, pages);
		this.table.update(rows.slice(this.page * pageSize, (this.page + 1) * pageSize));
	},

	/* Shown only when there is more than one page; the count is always
	 * worth having, so it stays either way. */
	renderPager(total, pages) {
		const step = L.bind(function(delta) {
			this.page = Math.min(pages - 1, Math.max(0, this.page + delta));
			this.redraw();
		}, this);

		const nav = [ E('span', { 'class': 'librespeed-muted' },
			[ N_(total, '%d measurement', '%d measurements').format(total) ]) ];

		if (pages > 1) {
			nav.push(E('button', {
				'class': 'cbi-button',
				'disabled': this.page > 0 ? null : '',
				'click': ui.createHandlerFn(this, function() { step(-1); })
			}, [ '\u2039 ' + _('Previous') ]));
			nav.push(E('span', { 'class': 'librespeed-muted' },
				[ _('Page %d of %d').format(this.page + 1, pages) ]));
			nav.push(E('button', {
				'class': 'cbi-button',
				'disabled': this.page < pages - 1 ? null : '',
				'click': ui.createHandlerFn(this, function() { step(1); })
			}, [ _('Next') + ' \u203a' ]));
		}

		this.pagerNode.replaceChildren(...nav);
	},

	handleExportCSV() {
		lscommon.exportCSV(this.visible(), this.resolution);
	},

	handleExportJSON() {
		lscommon.exportJSON(this.visible(), this.resolution);
	},

	render() {
		this.summaryNode = E('div', {});
		this.chartNode = E('div', {});
		this.controls = E('div', {});
		this.pagerNode = E('div', { 'class': 'librespeed-toolbar', 'style': 'margin:.5em 0' });

		/* ui.Table gives sortable headers for free -- every cell below is a
		 * [sort key, display] pair, so Time orders by epoch even though it
		 * shows a locale string, and numbers order numerically. The table is
		 * where sorting belongs; the chart above never reorders time. */
		this.table = new ui.Table([
			_('Time'), _('Server'), _('Interface'), _('Protocol'),
			_('Download [Mbps]'), _('Upload [Mbps]'), _('Ping [ms]'), _('Jitter [ms]')
		], { id: 'librespeed-history' });
		this.tableNode = this.table.render();
		/* The table's own handler re-sorts the page it holds; this runs
		 * after it and re-sorts the range, from the first page. */
		this.tableNode.addEventListener('click', L.bind(function(ev) {
			if (ev.target.closest('th[data-sortable-row], .th[data-sortable-row]')) {
				this.page = 0;
				this.redraw();
			}
		}, this));

		const renderControls = L.bind(function() {
			const groups = lscommon.switcher(this,
				[ [ 'speed', _('Speed') ], [ 'latency', _('Latency') ] ], this.group,
				function(v) { this.group = v; renderControls(); this.redraw(); }, _('Series'));
			const ranges = lscommon.switcher(this,
				lscommon.RANGES.map(r => [ String(r[1]), r[0] ]), String(this.range),
				function(v) {
					this.range = +v;
					this.page = 0;
					renderControls();
					return this.fetch().then(L.bind(function() {
						renderControls();
						this.redraw();
					}, this));
				}, _('Range'));

			ranges.classList.add('librespeed-push');

			/* Filter choices are whatever the fetched range actually contains. */
			const servers = [], ifaces = [];

			this.entries.forEach(e => {
				const s = e.server && e.server.name;
				if (s && servers.indexOf(s) < 0)
					servers.push(s);
				if (e.interface && ifaces.indexOf(e.interface) < 0)
					ifaces.push(e.interface);
			});

			/* The freshly fetched window may no longer contain the chosen
			 * server or interface -- daily aggregates carry neither field at
			 * all. The widget would then quietly repaint as "All" while the
			 * model kept filtering everything out, with no control on screen
			 * able to clear it; reconcile the model here, where the choice
			 * lists are built. */
			if (this.server && servers.indexOf(this.server) < 0)
				this.server = '';
			if (this.iface && ifaces.indexOf(this.iface) < 0)
				this.iface = '';

			const filters = E('div', { 'class': 'librespeed-toolbar' }, [
				this.filterSelect(_('Server'), servers.sort(), this.server,
					function(v) { this.server = v; this.page = 0; this.redraw(); }),
				this.filterSelect(_('Interface'), ifaces.sort(), this.iface,
					function(v) { this.iface = v; this.page = 0; this.redraw(); })
			]);

			this.controls.innerHTML = '';
			this.controls.appendChild(E('div', { 'class': 'librespeed-toolbar' },
				[ groups, ranges ]));
			this.controls.appendChild(filters);
		}, this);

		this.emptyText = E('p', { 'class': 'librespeed-muted', 'style': 'margin:.5em 0 1em' });
		this.emptyStart = E('a', {
			'class': 'cbi-button cbi-button-action',
			'href': L.url('admin', 'network', 'librespeed', 'test')
		}, [ _('Start test') ]);
		this.emptyNode = E('div', {
			'class': 'cbi-section librespeed-center',
			'style': 'display:none; margin-top:1em'
		}, [
			E('div', { 'style': 'font-size:1.4em; font-weight:600' },
				[ _('No measurements yet') ]),
			this.emptyText,
			this.emptyStart
		]);

		this.dataNode = E('div', {}, [
			this.chartNode,
			this.summaryNode,
			E('h3', { 'style': 'margin-top:.75em' }, [ _('Measurements') ]),
			this.tableNode,
			this.pagerNode,
			E('div', { 'class': 'cbi-page-actions' }, [
				E('button', { 'class': 'cbi-button', 'click': ui.createHandlerFn(this, 'handleExportCSV') },
					[ _('Export CSV') ]),
				' ',
				E('button', { 'class': 'cbi-button', 'click': ui.createHandlerFn(this, 'handleExportJSON') },
					[ _('Export JSON') ])
			])
		]);

		renderControls();
		this.redraw();

		return E([], [
			lscommon.cssLink(),
			E('h2', [ _('LibreSpeed – History') ]),
			this.controls,
			this.emptyNode,
			this.dataNode
		]);
	},

	handleSaveApply: null,
	handleSave: null,
	handleReset: null
});