mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-13 20:08:05 +08:00
🗽 Sync 2026-11-11 15:31:38
This commit is contained in:
1 parent
da9e4bea36
commit
2e3c0a9b33
189 files changed
+29797
-10433
No files matched your search
@@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require rpc';
|
||||
'require ui';
|
||||
|
||||
/* Shared between the Test page's recent-history block and the full History
|
||||
@@ -164,20 +165,54 @@ return baseclass.extend({
|
||||
/* One token->label table for the schedule intervals: Settings builds
|
||||
* its choices from it and the Test page looks the status label up, so
|
||||
* the two cannot drift. The init script accepts more shapes than these
|
||||
* six, so readers must fall back to the raw token. */
|
||||
* seven, so readers must fall back to the raw token. */
|
||||
INTERVALS: {
|
||||
'15m': _('Every 15 minutes'),
|
||||
'30m': _('Every 30 minutes'),
|
||||
'1h': _('Hourly'),
|
||||
'6h': _('Every 6 hours'),
|
||||
'12h': _('Every 12 hours'),
|
||||
'1d': _('Daily')
|
||||
'1d': _('Daily'),
|
||||
'7d': _('Weekly')
|
||||
},
|
||||
|
||||
/* Cron's weekday numbers, Monday first; an array keeps that order. */
|
||||
DAYS: [
|
||||
[ '1', _('Monday') ], [ '2', _('Tuesday') ], [ '3', _('Wednesday') ],
|
||||
[ '4', _('Thursday') ], [ '5', _('Friday') ], [ '6', _('Saturday') ],
|
||||
[ '0', _('Sunday') ]
|
||||
],
|
||||
|
||||
/* The interval and the weekdays as set; a token it does not know shows as it is. */
|
||||
scheduleLabel(interval, days) {
|
||||
const every = this.INTERVALS[interval || '1d'] || interval;
|
||||
const day = d => (this.DAYS.find(x => x[0] == d) || [ d, d ])[1];
|
||||
|
||||
if (!days || days == '*')
|
||||
return every;
|
||||
|
||||
return _('%s on %s', 'schedule interval on weekdays').format(every,
|
||||
String(days).split(',').map(p => p.split('-').map(day).join('–')).join(', '));
|
||||
},
|
||||
|
||||
/* Each interface's link: the name the backend records its results under. */
|
||||
callLinks: rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'links',
|
||||
expect: { '': {} }
|
||||
}),
|
||||
|
||||
/* Exported alongside the chart: every timestamp a page prints should
|
||||
* come through here, or the date-only guard gets lost in a copy. */
|
||||
chartStampFull: chartStampFull,
|
||||
|
||||
/* By epoch: the backend lists runs as recorded, which is out of order
|
||||
* for a run recorded before NTP set the clock. */
|
||||
byTime(entries) {
|
||||
return (Array.isArray(entries) ? entries.slice() : [])
|
||||
.sort((a, b) => (a.epoch ?? 0) - (b.epoch ?? 0));
|
||||
},
|
||||
|
||||
/* Plain figures of one series: what is normal, how much it wobbles, and
|
||||
* where it stands now. The story sentences are built from these. On
|
||||
* daily aggregates the extremes come from the _min/_max columns the
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
* second fetch, since the entries are in memory anyway. */
|
||||
const pageSize = 50;
|
||||
|
||||
/* Entries without an interface; '?' is outside the UCI name alphabet. */
|
||||
const UNKNOWN = '?';
|
||||
|
||||
function ifaceKey(e) {
|
||||
return e.interface || UNKNOWN;
|
||||
}
|
||||
|
||||
function ifaceLabel(key) {
|
||||
return (key == UNKNOWN) ? _('unknown') : key;
|
||||
}
|
||||
|
||||
const callConfig = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'config',
|
||||
@@ -36,6 +47,7 @@ return view.extend({
|
||||
range: 86400,
|
||||
server: '',
|
||||
iface: '',
|
||||
def: null,
|
||||
page: 0,
|
||||
|
||||
/* Which entries come back -- and at which resolution -- is the server's
|
||||
@@ -54,9 +66,9 @@ return view.extend({
|
||||
? 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.entries = lscommon.byTime(data[0].entries);
|
||||
this.resolution = data[0].resolution || 'raw';
|
||||
this.prevEntries = Array.isArray(data[1].entries) ? data[1].entries : [];
|
||||
this.prevEntries = lscommon.byTime(data[1].entries);
|
||||
this.prevResolution = data[1].resolution || 'raw';
|
||||
}, this));
|
||||
},
|
||||
@@ -67,32 +79,50 @@ return view.extend({
|
||||
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. */
|
||||
/* The config rides along for the default interface, and because
|
||||
* 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; }, () => {})
|
||||
]);
|
||||
callConfig().then(c => { this.config = c; }, () => {}),
|
||||
lscommon.callLinks().catch(() => ({}))
|
||||
]).then(data => {
|
||||
const def = this.config && this.config.interface;
|
||||
|
||||
this.def = (data[2].interfaces || {})[def] || def;
|
||||
});
|
||||
},
|
||||
|
||||
/* 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. */
|
||||
* daily aggregates carry no server, so there that filter offers only
|
||||
* "All", which matches everything. */
|
||||
applyFilters(entries) {
|
||||
return (entries || []).filter(e =>
|
||||
(!this.server || (e.server && e.server.name) == this.server) &&
|
||||
(!this.iface || e.interface == this.iface));
|
||||
ifaceKey(e) == this.iface);
|
||||
},
|
||||
|
||||
/* The default interface first, then by name, unknown last. */
|
||||
ifaceKeys(entries) {
|
||||
const rank = k => (k == this.def) ? 0 : ((k == UNKNOWN) ? 2 : 1),
|
||||
keys = [];
|
||||
|
||||
entries.forEach(e => {
|
||||
if (keys.indexOf(ifaceKey(e)) < 0)
|
||||
keys.push(ifaceKey(e));
|
||||
});
|
||||
|
||||
return keys.sort((a, b) => rank(a) - rank(b) || L.naturalCompare(a, b));
|
||||
},
|
||||
|
||||
visible() {
|
||||
return this.applyFilters(this.entries);
|
||||
},
|
||||
|
||||
filterSelect(title, values, current, onpick) {
|
||||
filterSelect(title, values, current, onpick, all, label) {
|
||||
const sel = E('select', { 'class': 'cbi-input-select' },
|
||||
[ E('option', { 'value': '' }, [ _('All') ]) ].concat(
|
||||
values.map(v => E('option', { 'value': v }, [ v ]))));
|
||||
(all ? [ E('option', { 'value': '' }, [ _('All') ]) ] : []).concat(
|
||||
values.map(v => E('option', { 'value': v }, [ label ? label(v) : v ]))));
|
||||
|
||||
sel.value = values.indexOf(current) >= 0 ? current : '';
|
||||
sel.addEventListener('change', ui.createHandlerFn(this, function() {
|
||||
@@ -248,6 +278,10 @@ return view.extend({
|
||||
this.chartNode.appendChild(E('p', { 'class': 'librespeed-muted' },
|
||||
[ _('Daily minimum, average and maximum.') ]));
|
||||
|
||||
if (this.iface == UNKNOWN)
|
||||
this.chartNode.appendChild(E('p', { 'class': 'librespeed-muted' },
|
||||
[ _('These results name no interface: days archived before results were kept per interface, which mix all links.') ]));
|
||||
|
||||
/* 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. */
|
||||
@@ -274,7 +308,6 @@ return view.extend({
|
||||
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)) ],
|
||||
@@ -351,7 +384,7 @@ return view.extend({
|
||||
* 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'),
|
||||
_('Time'), _('Server'), _('Protocol'),
|
||||
_('Download [Mbps]'), _('Upload [Mbps]'), _('Ping [ms]'), _('Jitter [ms]')
|
||||
], { id: 'librespeed-history' });
|
||||
this.tableNode = this.table.render();
|
||||
@@ -383,32 +416,30 @@ return view.extend({
|
||||
ranges.classList.add('librespeed-push');
|
||||
|
||||
/* Filter choices are whatever the fetched range actually contains. */
|
||||
const servers = [], ifaces = [];
|
||||
const servers = [], ifaces = this.ifaceKeys(this.entries);
|
||||
|
||||
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
|
||||
* server or interface -- daily aggregates may 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 = '';
|
||||
if (ifaces.indexOf(this.iface) < 0)
|
||||
this.iface = ifaces[0];
|
||||
|
||||
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(); })
|
||||
function(v) { this.server = v; this.page = 0; this.redraw(); }, true),
|
||||
ifaces.length ? this.filterSelect(_('Interface'), ifaces, this.iface,
|
||||
function(v) { this.iface = v; this.page = 0; this.redraw(); }, false, ifaceLabel) : ''
|
||||
]);
|
||||
|
||||
this.controls.innerHTML = '';
|
||||
|
||||
@@ -2,31 +2,60 @@
|
||||
'require view';
|
||||
'require form';
|
||||
'require rpc';
|
||||
'require tools.widgets as widgets';
|
||||
'require uci';
|
||||
'require librespeed.common as lscommon';
|
||||
|
||||
/* global lscommon */
|
||||
|
||||
const callConfig = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'config',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
/* The intervals the init script can put in cron; it leaves out any other. */
|
||||
const INTERVAL_RE = /^(?:(?:[1-9]|[1-5][0-9])m|(?:[1-9]|1[0-9]|2[0-3])h|1d|7d)$/;
|
||||
|
||||
return view.extend({
|
||||
load() {
|
||||
return callConfig().catch(() => ({}));
|
||||
return Promise.all([
|
||||
callConfig().catch(() => ({})),
|
||||
lscommon.callLinks().catch(() => ({})),
|
||||
uci.load('librespeed')
|
||||
]);
|
||||
},
|
||||
|
||||
render(config) {
|
||||
render(data) {
|
||||
const config = data[0],
|
||||
links = data[1].links || [],
|
||||
ifmap = data[1].interfaces || {},
|
||||
linkOf = n => ifmap[n] || n;
|
||||
let m, s, o;
|
||||
|
||||
/* Links, plus stored names that are not links: a save drops what is not offered. */
|
||||
const offer = (opt, stored) => {
|
||||
const label = n => {
|
||||
if (links.indexOf(ifmap[n]) >= 0)
|
||||
return _('%s (recorded as %s)').format(n, ifmap[n]);
|
||||
if (ifmap[n])
|
||||
return _('%s (no gateway)').format(n);
|
||||
return Object.keys(ifmap).length ? _('%s (not found)').format(n) : n;
|
||||
};
|
||||
|
||||
links.forEach(n => opt.value(n));
|
||||
stored.filter((n, i, a) => n && links.indexOf(n) < 0 && a.indexOf(n) == i)
|
||||
.forEach(n => opt.value(n, label(n)));
|
||||
};
|
||||
|
||||
m = new form.Map('librespeed', _('LibreSpeed – Settings'));
|
||||
|
||||
s = m.section(form.NamedSection, 'main', 'librespeed', _('Measurement'));
|
||||
|
||||
o = s.option(widgets.NetworkSelect, 'interface', _('Interface'),
|
||||
_('Logical interface the measurement is bound to.'));
|
||||
o = s.option(form.ListValue, 'interface', _('Interface'),
|
||||
_('Logical interface measured by default: by the Test page unless another one is chosen there, and by an automatic test that names no interface.'));
|
||||
o.default = 'wan';
|
||||
o.nocreate = true;
|
||||
offer(o, [ uci.get('librespeed', 'main', 'interface') || 'wan' ]);
|
||||
const mainIface = o;
|
||||
|
||||
o = s.option(form.Value, 'server', _('Server'),
|
||||
_("Numeric server id from the LibreSpeed server list, or 'auto' to pick the closest."));
|
||||
@@ -54,34 +83,97 @@ return view.extend({
|
||||
o.value('http', _('force HTTP'));
|
||||
o.default = 'auto';
|
||||
|
||||
s = m.section(form.NamedSection, 'schedule', 'librespeed', _('Scheduled measurements'));
|
||||
/* The help sits above the table, as a phone hides the column headings. */
|
||||
s = m.section(form.TableSection, 'schedule', _('Scheduled measurements'), [
|
||||
[ _('Enable'), _('Runs a measurement from cron at the chosen interval. Note that a measurement saturates the connection while it runs.') ],
|
||||
[ _('Days'), _('Leave empty to run every day. A weekly test runs on one of the chosen days, or on any day when none is chosen; the day is drawn once and kept while it stays among the chosen days.') ],
|
||||
[ _('Hours'), _('An hour window on the 24-hour clock, such as 2-5 for 02:00–05:59. A daily or weekly measurement runs at a random time inside it, drawn when the schedule is applied; shorter intervals only run within it. Leave empty for any time. A window across midnight is not supported.') ]
|
||||
].map(h => E('p', {}, [ '%s: %s'.format(h[0], h[1]) ])));
|
||||
s.anonymous = true;
|
||||
s.addremove = true;
|
||||
/* A new row gets the night window of the shipped first row. */
|
||||
s.handleAdd = function(ev, name) {
|
||||
const sid = this.map.data.add('librespeed', 'schedule', name);
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enable'),
|
||||
_('Runs a measurement from cron at the chosen interval. Note that a measurement saturates the connection while it runs.'));
|
||||
this.map.data.set('librespeed', sid, 'hours', '2-5');
|
||||
|
||||
return this.map.save(null, true);
|
||||
};
|
||||
/* The legacy named section first, as the init script reads it first. */
|
||||
s.cfgsections = function() {
|
||||
const legacy = (uci.get('librespeed', 'schedule') || {})['.type'] == 'librespeed';
|
||||
|
||||
return (legacy ? [ 'schedule' ] : []).concat(
|
||||
uci.sections('librespeed', 'schedule').map(x => x['.name']));
|
||||
};
|
||||
const rows = s.cfgsections();
|
||||
|
||||
o = s.option(form.ListValue, 'interface', _('Interface'));
|
||||
o.optional = true;
|
||||
o.placeholder = _('Default interface');
|
||||
offer(o, rows.map(sid => uci.get('librespeed', sid, 'interface')));
|
||||
/* Only enabled tests reach the crontab, one per link. The legacy row
|
||||
* without an interface measures the default interface chosen above. */
|
||||
const measured = (sid, v) => linkOf(v || (sid == 'schedule' ? mainIface.formvalue('main') : ''));
|
||||
o.validate = function(section_id, value) {
|
||||
if (!value && section_id != 'schedule')
|
||||
return _('Choose the interface this test measures.');
|
||||
|
||||
if (enabled.formvalue(section_id) != '1')
|
||||
return true;
|
||||
|
||||
const name = measured(section_id, value);
|
||||
const taken = this.section.cfgsections().some(sid => sid != section_id &&
|
||||
enabled.formvalue(sid) == '1' && measured(sid, this.formvalue(sid) ?? this.cfgvalue(sid)) == name);
|
||||
|
||||
return !taken || _('Another enabled automatic test already measures %s.').format(name);
|
||||
};
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enable'));
|
||||
const enabled = o;
|
||||
|
||||
o = s.option(form.ListValue, 'interval', _('Interval'));
|
||||
/* From the shared table, so the Test page's status label and these
|
||||
* choices cannot drift apart. */
|
||||
Object.keys(lscommon.INTERVALS).forEach(k =>
|
||||
o.value(k, lscommon.INTERVALS[k]));
|
||||
/* A hand-set interval is offered as it is, or a save would replace it. */
|
||||
rows.map(sid => uci.get('librespeed', sid, 'interval'))
|
||||
.filter((v, i, a) => v && !lscommon.INTERVALS[v] && a.indexOf(v) == i)
|
||||
.forEach(v => o.value(v));
|
||||
o.default = '1d';
|
||||
o.depends('enabled', '1');
|
||||
o.retain = true;
|
||||
/* Without a width the table squeezes the column and cuts the labels. */
|
||||
o.width = '13em';
|
||||
o.validate = function(section_id, value) {
|
||||
return INTERVAL_RE.test(value) ||
|
||||
_('The interval %s is not supported. Use 1-59m, 1-23h, 1d or 7d.').format(value);
|
||||
};
|
||||
|
||||
o = s.option(form.MultiValue, 'days', _('Days'),
|
||||
_('Leave empty to run every day.'));
|
||||
o.value('1', _('Monday'));
|
||||
o.value('2', _('Tuesday'));
|
||||
o.value('3', _('Wednesday'));
|
||||
o.value('4', _('Thursday'));
|
||||
o.value('5', _('Friday'));
|
||||
o.value('6', _('Saturday'));
|
||||
o.value('0', _('Sunday'));
|
||||
o = s.option(form.MultiValue, 'days', _('Days'));
|
||||
lscommon.DAYS.forEach(d => o.value(d[0], d[1]));
|
||||
o.optional = true;
|
||||
o.depends('enabled', '1');
|
||||
o.retain = true;
|
||||
/* The init script hands this straight to cron, which wants commas. */
|
||||
o.cfgvalue = function(section_id) {
|
||||
const v = form.MultiValue.prototype.cfgvalue.apply(this, arguments);
|
||||
return (v == null || v == '*') ? [] : String(v).split(',');
|
||||
const on = {};
|
||||
|
||||
if (v == null || v == '*')
|
||||
return [];
|
||||
|
||||
/* A hand-set range (1-5) as its days, so an untouched save keeps it. */
|
||||
String(v).split(',').forEach(p => {
|
||||
const r = p.match(/^([0-6])(?:-([0-6]))?$/);
|
||||
|
||||
if (r)
|
||||
for (let d = +r[1]; d <= +(r[2] ?? r[1]); d++)
|
||||
on[d] = true;
|
||||
});
|
||||
|
||||
return lscommon.DAYS.map(d => d[0]).filter(d => on[d]);
|
||||
};
|
||||
o.write = function(section_id, value) {
|
||||
const list = Array.isArray(value) ? value.filter(v => v !== '') : [];
|
||||
@@ -90,11 +182,11 @@ return view.extend({
|
||||
return this.super('write', [ section_id, list.join(',') ]);
|
||||
};
|
||||
|
||||
o = s.option(form.Value, 'hours', _('Hours'),
|
||||
_('An hour window on the 24-hour clock, such as 2-5 for 02:00–05:59. A daily measurement runs at a random time inside it, drawn when the schedule is applied; shorter intervals only run within it. Leave empty for any time. A window across midnight is not supported.'));
|
||||
o = s.option(form.Value, 'hours', _('Hours'));
|
||||
o.placeholder = '2-5';
|
||||
o.optional = true;
|
||||
o.depends('enabled', '1');
|
||||
o.retain = true;
|
||||
o.validate = function(section_id, value) {
|
||||
if (value == '')
|
||||
return true;
|
||||
@@ -138,22 +230,25 @@ return view.extend({
|
||||
o.depends('enabled', '1');
|
||||
|
||||
return m.render().then(node => {
|
||||
const cron = config && config.schedule && config.schedule.cron;
|
||||
const scheds = config.schedules || [];
|
||||
const box = E('div', { 'style': 'margin-top:1em' });
|
||||
|
||||
if (cron) {
|
||||
if (scheds.length) {
|
||||
/* Epochs computed by the backend in the router's timezone. */
|
||||
const runs = (config && config.schedule && config.schedule.next_runs || [])
|
||||
.map(e => new Date(e * 1000));
|
||||
|
||||
box.appendChild(E('h4', {}, [ _('Upcoming measurements') ]));
|
||||
box.appendChild(E('table', { 'class': 'table', 'style': 'max-width:24em' },
|
||||
runs.map(d => E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, [ d.toLocaleDateString() ]),
|
||||
E('td', { 'class': 'td' }, [ d.toLocaleTimeString() ])
|
||||
]))));
|
||||
box.appendChild(E('table', { 'class': 'table', 'style': 'max-width:32em' },
|
||||
scheds.flatMap(sc => (sc.next_runs || []).map(e => {
|
||||
const d = new Date(e * 1000);
|
||||
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, [ linkOf(sc.interface) ]),
|
||||
E('td', { 'class': 'td' }, [ d.toLocaleDateString(undefined,
|
||||
{ weekday: 'short', year: 'numeric', month: 'numeric', day: 'numeric' }) ]),
|
||||
E('td', { 'class': 'td' }, [ d.toLocaleTimeString() ])
|
||||
]);
|
||||
}))));
|
||||
box.appendChild(E('p', { 'style': 'color:#888' }, [
|
||||
_('A daily measurement runs at a time drawn when the schedule is applied; saving draws it anew.')
|
||||
_('The time of a daily measurement is drawn anew whenever the schedule is applied. A weekly measurement keeps its drawn day and time while they fit the chosen days and hours.')
|
||||
]));
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
const callStart = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'start',
|
||||
params: [ 'interface' ],
|
||||
/* A ubus error must not read as a start. */
|
||||
reject: true,
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
@@ -201,6 +204,7 @@ return view.extend({
|
||||
button: null,
|
||||
lastSeen: null,
|
||||
config: {},
|
||||
ifmap: {},
|
||||
|
||||
load() {
|
||||
return Promise.all([
|
||||
@@ -208,23 +212,27 @@ return view.extend({
|
||||
callResult().catch(() => ({})),
|
||||
callConfig().catch(() => ({})),
|
||||
callHistory(Math.floor(Date.now() / 1000 - recentSeconds),
|
||||
undefined, 0).catch(() => ({}))
|
||||
undefined, 0).catch(() => ({})),
|
||||
lscommon.callLinks().catch(() => ({}))
|
||||
]);
|
||||
},
|
||||
|
||||
renderRecent(data) {
|
||||
const entries = Array.isArray(data && data.entries) ? data.entries : [];
|
||||
/* One interface only: an average over WAN and LTE describes neither. */
|
||||
const all = lscommon.byTime(data && data.entries);
|
||||
const entries = all.filter(e => e.interface == this.iface);
|
||||
|
||||
/* Kept so switching the metric can redraw without refetching. */
|
||||
this.recentData = data;
|
||||
|
||||
this.recentNode.innerHTML = '';
|
||||
this.recentSubtitle.textContent = '%s · %s'.format(_('last 24 hours'), this.iface);
|
||||
this.recentCount.textContent = entries.length
|
||||
? N_(entries.length, '%d measurement', '%d measurements')
|
||||
.format(entries.length) : '';
|
||||
|
||||
/* The active button names the metric, so the subtitle only carries
|
||||
* the window. */
|
||||
* the window and the interface. */
|
||||
this.recentSwitch.replaceChildren(...lscommon.switcher(this,
|
||||
lscommon.GROUPS.speed.map(k => {
|
||||
const m = lscommon.METRICS.find(x => x[0] == k);
|
||||
@@ -277,6 +285,17 @@ return view.extend({
|
||||
if (!drew) {
|
||||
this.recentNode.appendChild(E('p', { 'class': 'librespeed-muted' },
|
||||
[ _('No data in the last 24 hours.') ]));
|
||||
|
||||
const others = all.length - entries.length;
|
||||
|
||||
if (!entries.length && others > 0)
|
||||
this.recentNode.appendChild(E('p', { 'class': 'librespeed-muted' }, [
|
||||
N_(others, '%d measurement of another interface is in the full history.',
|
||||
'%d measurements of other interfaces are in the full history.').format(others),
|
||||
' ',
|
||||
E('a', { 'href': L.url('admin', 'network', 'librespeed', 'history') },
|
||||
[ _('View full history »') ])
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -328,19 +347,20 @@ return view.extend({
|
||||
},
|
||||
|
||||
handleStart(ev) {
|
||||
return callStart().then(L.bind(function(res) {
|
||||
if (res && res.error) {
|
||||
ui.addNotification(null, E('p', [
|
||||
_('Could not start the measurement: %s').format(res.error)
|
||||
]));
|
||||
return;
|
||||
}
|
||||
/* A raised RPC error carries LuCI's stack trace after its first line. */
|
||||
const fail = msg => ui.addNotification(null, E('p', [
|
||||
_('Could not start the measurement: %s').format(String(msg).split('\n')[0])
|
||||
]));
|
||||
|
||||
return callStart(this.iface).then(L.bind(function(res) {
|
||||
if (res && res.error)
|
||||
return fail(res.error);
|
||||
|
||||
/* Show the running state immediately rather than waiting up to
|
||||
* pollInterval for the next status. */
|
||||
this.renderStatus({ running: true, started: Date.now() / 1000 });
|
||||
this.setBusy(true);
|
||||
}, this));
|
||||
}, this), err => fail(err.message));
|
||||
},
|
||||
|
||||
handleStop(ev) {
|
||||
@@ -360,13 +380,18 @@ return view.extend({
|
||||
running ? 'handleStop' : 'handleStart');
|
||||
},
|
||||
|
||||
runContext() {
|
||||
linkOf(name) {
|
||||
return this.ifmap[name] || name;
|
||||
},
|
||||
|
||||
runContext(status) {
|
||||
const parts = [];
|
||||
|
||||
if (this.config.server && this.config.server != 'auto')
|
||||
parts.push(_('server %s').format(this.config.server));
|
||||
if (this.config.interface)
|
||||
parts.push(this.config.interface);
|
||||
/* As the backend names it: a cron run may measure another interface. */
|
||||
if (status.interface)
|
||||
parts.push(status.interface);
|
||||
|
||||
return parts.join(' · ');
|
||||
},
|
||||
@@ -462,7 +487,7 @@ return view.extend({
|
||||
rows.push(E('div', { 'class': 'librespeed-muted' },
|
||||
[ _('%s elapsed').format(elapsed) ]));
|
||||
|
||||
const ctx = this.runContext();
|
||||
const ctx = this.runContext(status);
|
||||
|
||||
if (ctx)
|
||||
rows.push(E('div', { 'class': 'librespeed-muted', 'style': 'margin-top:.5em' }, [ ctx ]));
|
||||
@@ -480,19 +505,21 @@ return view.extend({
|
||||
status.last_finished ? ' · ' + fmtTime(status.last_finished) : ''
|
||||
]));
|
||||
}
|
||||
else if (!status.running && status.last_error) {
|
||||
|
||||
/* One per interface, kept until a run of it succeeds. */
|
||||
(status.running ? [] : status.failures || []).forEach(f => {
|
||||
/* Compact, and above the figures: the failure is news, the last
|
||||
* good result is still the substance of the page. */
|
||||
this.alertNode.appendChild(E('div', {
|
||||
this.alertNode.appendChild(E('div', {}, [ E('div', {
|
||||
'class': 'alert-message warning',
|
||||
'style': 'display:inline-block; text-align:left'
|
||||
}, [
|
||||
E('strong', {}, [ _('Last measurement failed') ]),
|
||||
E('strong', {}, [ _('Last measurement of %s failed').format(this.linkOf(f.interface)) ]),
|
||||
E('br'),
|
||||
status.last_error,
|
||||
status.last_finished ? ' · ' + fmtTime(status.last_finished) : ''
|
||||
]));
|
||||
}
|
||||
f.error,
|
||||
f.finished ? ' · ' + fmtTime(f.finished) : ''
|
||||
]) ]));
|
||||
});
|
||||
},
|
||||
|
||||
renderResult(result) {
|
||||
@@ -586,9 +613,12 @@ return view.extend({
|
||||
const status = data[0] || {},
|
||||
result = data[1] || {},
|
||||
config = data[2] || {},
|
||||
recent = data[3] || {};
|
||||
recent = data[3] || {},
|
||||
links = data[4] || {};
|
||||
|
||||
this.config = config;
|
||||
this.ifmap = links.interfaces || {};
|
||||
this.iface = this.linkOf(config.interface || 'wan');
|
||||
this.alertNode = E('div', {});
|
||||
this.statusNode = E('div', {});
|
||||
this.resultHeading = E('div', { 'class': 'librespeed-muted', 'style': 'margin-top:.35em' });
|
||||
@@ -600,8 +630,7 @@ return view.extend({
|
||||
'class': 'librespeed-visually-hidden'
|
||||
});
|
||||
this.recentCount = E('span', { 'class': 'librespeed-muted' });
|
||||
this.recentSubtitle = E('div', { 'class': 'librespeed-muted' },
|
||||
[ _('last 24 hours') ]);
|
||||
this.recentSubtitle = E('div', { 'class': 'librespeed-muted' });
|
||||
/* Built once and refilled: ui.Table gives sortable headers, the same
|
||||
* as the History page's table. */
|
||||
this.recentTable = new ui.Table([
|
||||
@@ -618,6 +647,28 @@ return view.extend({
|
||||
this.button = E('button', { 'class': 'cbi-button cbi-button-action' },
|
||||
[ _('Start test') ]);
|
||||
|
||||
/* The backend's links, or the default without any; the choice lasts
|
||||
* for this page only. A default that is not a link is not chosen. */
|
||||
const ifaces = (links.links || []).slice();
|
||||
let note = '';
|
||||
|
||||
if (!ifaces.length)
|
||||
ifaces.push(this.iface);
|
||||
else if (ifaces.indexOf(this.iface) < 0) {
|
||||
note = _('The default interface %s is not available.').format(config.interface || 'wan');
|
||||
this.iface = ifaces[0];
|
||||
}
|
||||
|
||||
const ifaceSelect = E('select', {
|
||||
'class': 'cbi-input-select',
|
||||
'change': ev => {
|
||||
this.iface = ev.target.value;
|
||||
this.renderRecent(this.recentData);
|
||||
}
|
||||
}, ifaces.map(n => E('option', { 'value': n }, [ n ])));
|
||||
|
||||
ifaceSelect.value = this.iface;
|
||||
|
||||
this.lastSeen = status.last_finished;
|
||||
|
||||
this.renderStatus(status);
|
||||
@@ -668,20 +719,17 @@ return view.extend({
|
||||
|
||||
/* Computed by the backend in the router's timezone; the browser may
|
||||
* well sit in another one, so it only formats the epoch. */
|
||||
const sched = config.schedule || {};
|
||||
const next = sched.next_runs || [];
|
||||
/* Strict, matching history.js: the backend derives this from the
|
||||
* crontab and emits a real boolean, never UCI's '0'/'1'. */
|
||||
const schedRows = [ [ _('Enabled'), sched.enabled === true ? _('Yes') : _('No') ] ];
|
||||
const schedRows = (config.schedules || []).map(sc => {
|
||||
const every = lscommon.scheduleLabel(sc.interval, sc.days),
|
||||
next = (sc.next_runs || [])[0];
|
||||
|
||||
if (sched.enabled === true) {
|
||||
/* The shared table translates the six tokens the UI offers; the
|
||||
* init script accepts more, so the raw token is the fallback. */
|
||||
schedRows.push([ _('Interval'),
|
||||
lscommon.INTERVALS[sched.interval || '1d'] || sched.interval ]);
|
||||
if (next.length)
|
||||
schedRows.push([ _('Next run'), new Date(next[0] * 1000).toLocaleString() ]);
|
||||
}
|
||||
return [ this.linkOf(sc.interface), next
|
||||
? '%s · %s'.format(every, new Date(next * 1000).toLocaleString())
|
||||
: every ];
|
||||
});
|
||||
|
||||
if (!schedRows.length)
|
||||
schedRows.push([ _('Enabled'), _('No') ]);
|
||||
|
||||
/* The main card carries the result; the aside carries what the next
|
||||
* run will do. Plain sections and tables, so every theme renders it. */
|
||||
@@ -689,7 +737,6 @@ return view.extend({
|
||||
E('div', { 'class': 'cbi-section', 'style': 'margin:0 0 1em 0' }, [
|
||||
E('h3', [ _('Test configuration') ]),
|
||||
kv([
|
||||
[ _('Interface'), config.interface || 'wan' ],
|
||||
[ _('Server'), (config.server == 'auto' || !config.server)
|
||||
? _('automatic (nearest)') : config.server ],
|
||||
[ _('Protocol'),
|
||||
@@ -715,7 +762,17 @@ return view.extend({
|
||||
this.statusNode,
|
||||
this.resultHeading,
|
||||
this.resultNode,
|
||||
E('div', { 'style': 'margin-top:1em' }, [ this.button ])
|
||||
E('div', {
|
||||
'class': 'librespeed-toolbar',
|
||||
'style': 'justify-content:center; margin-top:1em'
|
||||
}, [
|
||||
E('label', {}, [
|
||||
E('span', { 'class': 'librespeed-muted' }, [ _('Interface') ]),
|
||||
' ', ifaceSelect
|
||||
]),
|
||||
this.button
|
||||
]),
|
||||
note ? E('p', { 'class': 'librespeed-muted', 'style': 'margin:.5em 0 0' }, [ note ]) : ''
|
||||
]);
|
||||
|
||||
const recentSection = E('div', { 'class': 'cbi-section', 'style': 'margin:1.5em 0 0 0' }, [
|
||||
|
||||
Reference in new issue
Block a user