🗽 Sync 2026-11-11 15:31:38

This commit is contained in:
github-actions[bot] committed 2026-11-11 15:31:38 +08:00
1 parent da9e4bea36
commit 2e3c0a9b33
189 files changed
+29797 -10433

No files matched your search

@@ -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' }, [