mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 10:53:56 +08:00
466 lines
16 KiB
JavaScript
466 lines
16 KiB
JavaScript
'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
|
||
});
|