Files

466 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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
});