mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 18:44:03 +08:00
🎈 Sync 2026-08-24 18:23:48
This commit is contained in:
1 parent
59dbc6347f
commit
0b3ba53026
119 files changed
+2501
-2677
No files matched your search
@@ -46,10 +46,6 @@ function download(name, mime, text) {
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/* Bumped whenever librespeed.css changes, so a routine page load picks the
|
||||
* new stylesheet up instead of pairing new views with a cached old one. */
|
||||
const CSS_VERSION = '18';
|
||||
|
||||
function metricOf(key) {
|
||||
return METRICS.find(m => m[0] == key);
|
||||
}
|
||||
@@ -142,46 +138,79 @@ return baseclass.extend({
|
||||
GROUPS: GROUPS,
|
||||
RANGES: RANGES,
|
||||
|
||||
/* The app stylesheet, cache-busted by its own version: LuCI's resource
|
||||
* version only changes when luci-base does, which is not when we do. */
|
||||
/* Linked plainly, the way the other applications do: uhttpd serves
|
||||
* static resources with ETag revalidation, so an upgraded stylesheet
|
||||
* reaches the browser without a cache-busting version to maintain. */
|
||||
cssLink() {
|
||||
return E('link', {
|
||||
'rel': 'stylesheet',
|
||||
'href': L.resource('librespeed/librespeed.css') + '?v=' + CSS_VERSION
|
||||
'href': L.resource('librespeed/librespeed.css')
|
||||
});
|
||||
},
|
||||
|
||||
/* 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. */
|
||||
INTERVALS: {
|
||||
'15m': _('Every 15 minutes'),
|
||||
'30m': _('Every 30 minutes'),
|
||||
'1h': _('Hourly'),
|
||||
'6h': _('Every 6 hours'),
|
||||
'12h': _('Every 12 hours'),
|
||||
'1d': _('Daily')
|
||||
},
|
||||
|
||||
/* 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,
|
||||
|
||||
/* Plain figures of one series: what is normal, how much it wobbles, and
|
||||
* where it stands now. The story sentences are built from these. */
|
||||
seriesStats(entries, metric) {
|
||||
* where it stands now. The story sentences are built from these. On
|
||||
* daily aggregates the extremes come from the _min/_max columns the
|
||||
* chart also draws -- min/max of the daily means would understate the
|
||||
* spread and contradict the band directly above the sentence. */
|
||||
seriesStats(entries, metric, resolution) {
|
||||
const vals = (entries || []).map(e => e[metric]).filter(v => typeof v == 'number');
|
||||
|
||||
if (!vals.length)
|
||||
return null;
|
||||
|
||||
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
const lows = (resolution == '1d')
|
||||
? (entries || []).map(e => typeof e[metric + '_min'] == 'number'
|
||||
? e[metric + '_min'] : e[metric]).filter(v => typeof v == 'number')
|
||||
: vals;
|
||||
const highs = (resolution == '1d')
|
||||
? (entries || []).map(e => typeof e[metric + '_max'] == 'number'
|
||||
? e[metric + '_max'] : e[metric]).filter(v => typeof v == 'number')
|
||||
: vals;
|
||||
|
||||
return {
|
||||
count: vals.length,
|
||||
avg: avg,
|
||||
min: Math.min.apply(null, vals),
|
||||
max: Math.max.apply(null, vals),
|
||||
min: Math.min.apply(null, lows),
|
||||
max: Math.max.apply(null, highs),
|
||||
current: vals[vals.length - 1]
|
||||
};
|
||||
},
|
||||
|
||||
/* A row of toggle buttons; the active one is highlighted. */
|
||||
switcher(view, items, current, onpick) {
|
||||
return E('div', {}, items.map(it =>
|
||||
E('button', {
|
||||
'class': it[0] == current ? 'cbi-button cbi-button-action' : 'cbi-button',
|
||||
'style': 'margin-right:.3em',
|
||||
'click': ui.createHandlerFn(view, function() {
|
||||
return onpick.call(view, it[0]);
|
||||
})
|
||||
/* Callers hand in display-ready labels: a runtime _() here
|
||||
* would both miss the extractor and translate twice. */
|
||||
}, [ it[1] != null ? it[1] : it[0] ])));
|
||||
/* A row of toggle buttons; the active one is highlighted. The state is
|
||||
* also spoken: colour alone says nothing to a screen reader, so each
|
||||
* button carries aria-pressed and the group a name for what it picks. */
|
||||
switcher(view, items, current, onpick, label) {
|
||||
return E('div', { 'role': 'group', 'aria-label': label || null },
|
||||
items.map(it =>
|
||||
E('button', {
|
||||
'class': it[0] == current ? 'cbi-button cbi-button-action' : 'cbi-button',
|
||||
'style': 'margin-right:.3em',
|
||||
'aria-pressed': it[0] == current ? 'true' : 'false',
|
||||
'click': ui.createHandlerFn(view, function() {
|
||||
return onpick.call(view, it[0]);
|
||||
})
|
||||
/* Callers hand in display-ready labels: a runtime _() here
|
||||
* would both miss the extractor and translate twice. */
|
||||
}, [ it[1] != null ? it[1] : it[0] ])));
|
||||
},
|
||||
|
||||
/* The one chart component: hand-built SVG, the way the realtime status
|
||||
@@ -254,8 +283,9 @@ return baseclass.extend({
|
||||
const avg = valid.reduce((a, p) => a + p[2], 0) / valid.length;
|
||||
body += '<line class="librespeed-avgline librespeed-stroke-%d" x1="%d" y1="%f" x2="%d" y2="%f"/>'
|
||||
.format(ci, L, sc.ys(avg), W - R, sc.ys(avg));
|
||||
body += '<text x="%d" y="%f" font-size="9" fill="currentColor" fill-opacity=".55" text-anchor="end">avg %s</text>'
|
||||
.format(W - R, sc.ys(avg) - 4, avg.toFixed(avg >= 100 ? 0 : 1));
|
||||
body += '<text x="%d" y="%f" font-size="9" fill="currentColor" fill-opacity=".55" text-anchor="end">%h %s</text>'
|
||||
.format(W - R, sc.ys(avg) - 4, _('avg'),
|
||||
avg.toFixed(avg >= 100 ? 0 : 1));
|
||||
|
||||
body += (valid.length > 1)
|
||||
? '<polyline class="librespeed-line librespeed-stroke-%d" points="%s"/>'.format(ci, pts)
|
||||
@@ -314,7 +344,7 @@ return baseclass.extend({
|
||||
|
||||
opts.legend.all.forEach(k => {
|
||||
const m = metricOf(k);
|
||||
const st = this.seriesStats(entries, k);
|
||||
const st = this.seriesStats(entries, k, opts.resolution);
|
||||
const cb = E('input', { 'type': 'checkbox' });
|
||||
|
||||
cb.checked = opts.series.indexOf(k) >= 0;
|
||||
@@ -426,7 +456,8 @@ return baseclass.extend({
|
||||
if (typeof e[k] != 'number')
|
||||
return;
|
||||
let txt = fmtVal(e[k], m[2]);
|
||||
if (typeof e[k + '_min'] == 'number')
|
||||
if (typeof e[k + '_min'] == 'number' &&
|
||||
typeof e[k + '_max'] == 'number')
|
||||
txt += ' (%s – %s)'.format(
|
||||
e[k + '_min'].toFixed(m[2] == 'Mbps' ? 0 : 1),
|
||||
e[k + '_max'].toFixed(m[2] == 'Mbps' ? 0 : 1));
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/* The theme's accent where the theme publishes one: --primary-color-medium
|
||||
* exists only in luci-theme-bootstrap. Every other in-tree theme sees the
|
||||
* fallback, which must clear 3:1 against a light page -- the arc and the
|
||||
* history line are the only graphical encoding of the data. */
|
||||
:root {
|
||||
--librespeed-accent: var(--primary-color-medium, #3c8dbc);
|
||||
}
|
||||
|
||||
/* Dark is an attribute in LuCI, not a media preference: bootstrap stamps
|
||||
* data-darkmode on the root and pins it for its light/dark variants, while
|
||||
* the themes that actually reach the fallback have no dark mode at all --
|
||||
* an OS-level preference must not darken their light pages. */
|
||||
:root[data-darkmode="true"] {
|
||||
--librespeed-accent: var(--primary-color-medium, #6ab0de);
|
||||
}
|
||||
|
||||
/* The one piece of CSS this application owns: layout and type scale. Colors,
|
||||
* borders, buttons and spacing inside sections stay with the theme. */
|
||||
|
||||
@@ -55,31 +71,31 @@
|
||||
max-width: 20em;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
accent-color: var(--primary-color-medium, #5bc0de);
|
||||
accent-color: var(--librespeed-accent);
|
||||
}
|
||||
|
||||
/* The chart takes its color from the theme where one is exposed, and falls
|
||||
* back to a neutral accent where none is. */
|
||||
.librespeed-chart-line {
|
||||
stroke: var(--primary-color-medium, #5bc0de);
|
||||
stroke: var(--librespeed-accent);
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.librespeed-chart-fill {
|
||||
fill: var(--primary-color-medium, #5bc0de);
|
||||
fill: var(--librespeed-accent);
|
||||
fill-opacity: .1;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.librespeed-chart-band {
|
||||
fill: var(--primary-color-medium, #5bc0de);
|
||||
fill: var(--librespeed-accent);
|
||||
fill-opacity: .18;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.librespeed-chart-dot {
|
||||
fill: var(--primary-color-medium, #5bc0de);
|
||||
fill: var(--librespeed-accent);
|
||||
}
|
||||
|
||||
.librespeed-toolbar {
|
||||
@@ -138,7 +154,7 @@
|
||||
|
||||
.librespeed-gauge-fill {
|
||||
fill: none;
|
||||
stroke: var(--primary-color-medium, #5bc0de);
|
||||
stroke: var(--librespeed-accent);
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
@@ -219,17 +235,17 @@
|
||||
/* One theme color for every series: the second one differs by pattern, not
|
||||
* by a hardcoded hue -- a dashed line and a striped swatch survive any theme
|
||||
* and read for color-blind users too. */
|
||||
.librespeed-stroke-0 { stroke: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-stroke-1 { stroke: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-stroke-0 { stroke: var(--librespeed-accent); }
|
||||
.librespeed-stroke-1 { stroke: var(--librespeed-accent); }
|
||||
polyline.librespeed-stroke-1 { stroke-dasharray: 6 3; }
|
||||
.librespeed-avgline.librespeed-stroke-1 { stroke-dasharray: 2 4; }
|
||||
.librespeed-fill-0 { fill: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-fill-1 { fill: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-fill-0 { fill: var(--librespeed-accent); }
|
||||
.librespeed-fill-1 { fill: var(--librespeed-accent); }
|
||||
circle.librespeed-fill-1 { fill-opacity: .55; }
|
||||
.librespeed-bg-0 { background: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-bg-0 { background: var(--librespeed-accent); }
|
||||
.librespeed-bg-1 {
|
||||
background: repeating-linear-gradient(90deg,
|
||||
var(--primary-color-medium, #5bc0de) 0 4px, transparent 4px 7px);
|
||||
var(--librespeed-accent) 0 4px, transparent 4px 7px);
|
||||
}
|
||||
|
||||
.librespeed-legend {
|
||||
@@ -266,8 +282,8 @@ circle.librespeed-fill-1 { fill-opacity: .55; }
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.librespeed-fg-0 { color: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-fg-1 { color: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-fg-0 { color: var(--librespeed-accent); }
|
||||
.librespeed-fg-1 { color: var(--librespeed-accent); }
|
||||
|
||||
.librespeed-cards {
|
||||
display: flex;
|
||||
@@ -283,15 +299,17 @@ circle.librespeed-fill-1 { fill-opacity: .55; }
|
||||
padding: .45em .9em;
|
||||
min-width: 11em;
|
||||
cursor: pointer;
|
||||
opacity: .5;
|
||||
/* Dim, still legible: .5 with the muted name inside multiplied down to
|
||||
* an unreadable third. */
|
||||
opacity: .78;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.librespeed-metric-card-on { opacity: 1; }
|
||||
.librespeed-metric-card:hover { border-color: rgba(128, 128, 128, .45); }
|
||||
|
||||
.librespeed-accent-0 { border-top-color: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-accent-1 { border-top-color: var(--primary-color-medium, #5bc0de); border-top-style: dashed; }
|
||||
.librespeed-accent-0 { border-top-color: var(--librespeed-accent); }
|
||||
.librespeed-accent-1 { border-top-color: var(--librespeed-accent); border-top-style: dashed; }
|
||||
|
||||
.librespeed-card-value {
|
||||
font-size: 1.3em;
|
||||
@@ -313,3 +331,14 @@ circle.librespeed-fill-1 { fill-opacity: .55; }
|
||||
text-align: left;
|
||||
font-size: .95em;
|
||||
}
|
||||
|
||||
/* Off-screen but reachable: the live region announcing the running phase,
|
||||
* and the gauge figures repeated as plain text for browse mode. */
|
||||
.librespeed-visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -14,6 +14,12 @@
|
||||
* 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',
|
||||
@@ -61,7 +67,13 @@ return view.extend({
|
||||
speed: lscommon.GROUPS.speed.slice(),
|
||||
latency: lscommon.GROUPS.latency.slice()
|
||||
};
|
||||
return this.fetch();
|
||||
/* 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;
|
||||
@@ -107,8 +119,8 @@ return view.extend({
|
||||
|
||||
lscommon.GROUPS[this.group].forEach(L.bind(function(k, ki) {
|
||||
const m = lscommon.METRICS.find(x => x[0] == k),
|
||||
st = lscommon.seriesStats(entries, k),
|
||||
ps = lscommon.seriesStats(prev, k),
|
||||
st = lscommon.seriesStats(entries, k, this.resolution),
|
||||
ps = lscommon.seriesStats(prev, k, this.prevResolution),
|
||||
on = act.indexOf(k) >= 0;
|
||||
|
||||
let diff = ' ';
|
||||
@@ -146,7 +158,9 @@ return view.extend({
|
||||
(on ? ' librespeed-metric-card-on' : ''),
|
||||
'title': on ? _('Hide this series') : _('Show this series')
|
||||
}, [
|
||||
E('div', { 'class': 'librespeed-muted librespeed-caps' },
|
||||
/* 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] : '–',
|
||||
@@ -189,10 +203,24 @@ return view.extend({
|
||||
const noneAtAll = !this.entries.length;
|
||||
|
||||
if (!entries.length) {
|
||||
this.emptyText.textContent = noneAtAll
|
||||
? _('Run a speed test to start building your connection history.')
|
||||
: _('No measurements match the current filters.');
|
||||
/* 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;
|
||||
@@ -220,17 +248,12 @@ return view.extend({
|
||||
this.chartNode.appendChild(E('p', { 'class': 'librespeed-muted' },
|
||||
[ _('Daily minimum, average and maximum.') ]));
|
||||
|
||||
const num = v => (typeof v == 'number') ? v : -1;
|
||||
/* 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 => {
|
||||
if (!e.timestamp)
|
||||
return '?';
|
||||
if (this.resolution == '1d' || e.timestamp.indexOf('T') < 0)
|
||||
return e.timestamp;
|
||||
const d = new Date(e.timestamp);
|
||||
/* A garbled timestamp shows as itself, never as "Invalid Date". */
|
||||
return isNaN(d.getTime()) ? e.timestamp : d.toLocaleString();
|
||||
};
|
||||
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. */
|
||||
@@ -244,15 +267,19 @@ return view.extend({
|
||||
* 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, when(e) ],
|
||||
(e.server && e.server.name) || '–',
|
||||
e.interface || '–',
|
||||
protoCell(e),
|
||||
[ num(e.download_mbps), fmtNum(e.download_mbps, 2) ],
|
||||
[ num(e.upload_mbps), fmtNum(e.upload_mbps, 2) ],
|
||||
[ num(e.ping_ms), fmtNum(e.ping_ms, 1) ],
|
||||
[ num(e.jitter_ms), fmtNum(e.jitter_ms, 1) ]
|
||||
[ 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();
|
||||
@@ -274,7 +301,6 @@ return view.extend({
|
||||
|
||||
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
|
||||
@@ -286,7 +312,7 @@ return view.extend({
|
||||
}, this);
|
||||
|
||||
const nav = [ E('span', { 'class': 'librespeed-muted' },
|
||||
[ _('%d measurements').format(total) ]) ];
|
||||
[ N_(total, '%d measurement', '%d measurements').format(total) ]) ];
|
||||
|
||||
if (pages > 1) {
|
||||
nav.push(E('button', {
|
||||
@@ -341,7 +367,7 @@ return view.extend({
|
||||
const renderControls = L.bind(function() {
|
||||
const groups = lscommon.switcher(this,
|
||||
[ [ 'speed', _('Speed') ], [ 'latency', _('Latency') ] ], this.group,
|
||||
function(v) { this.group = v; renderControls(); this.redraw(); });
|
||||
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) {
|
||||
@@ -352,7 +378,7 @@ return view.extend({
|
||||
renderControls();
|
||||
this.redraw();
|
||||
}, this));
|
||||
});
|
||||
}, _('Range'));
|
||||
|
||||
ranges.classList.add('librespeed-push');
|
||||
|
||||
@@ -367,6 +393,17 @@ return view.extend({
|
||||
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(); }),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
'require form';
|
||||
'require rpc';
|
||||
'require tools.widgets as widgets';
|
||||
'require librespeed.common as lscommon';
|
||||
|
||||
const callConfig = rpc.declare({
|
||||
object: 'librespeed',
|
||||
@@ -59,12 +60,10 @@ return view.extend({
|
||||
_('Runs a measurement from cron at the chosen interval. Note that a measurement saturates the connection while it runs.'));
|
||||
|
||||
o = s.option(form.ListValue, 'interval', _('Interval'));
|
||||
o.value('15m', _('Every 15 minutes'));
|
||||
o.value('30m', _('Every 30 minutes'));
|
||||
o.value('1h', _('Hourly'));
|
||||
o.value('6h', _('Every 6 hours'));
|
||||
o.value('12h', _('Every 12 hours'));
|
||||
o.value('1d', _('Daily'));
|
||||
/* 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]));
|
||||
o.default = '1d';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
@@ -163,8 +162,11 @@ return view.extend({
|
||||
]));
|
||||
}
|
||||
|
||||
node.appendChild(box);
|
||||
return node;
|
||||
/* A sibling of the form, not a child: Map.render() replaces its
|
||||
* own root on every Save and Reset, and would take the schedule
|
||||
* preview with it. The preview reads the applied crontab, so it
|
||||
* is correct to keep it as it is until Apply. */
|
||||
return E([], [ node, box ]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -220,7 +220,8 @@ return view.extend({
|
||||
|
||||
this.recentNode.innerHTML = '';
|
||||
this.recentCount.textContent = entries.length
|
||||
? _('%d measurements').format(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. */
|
||||
@@ -232,12 +233,12 @@ return view.extend({
|
||||
function(v) {
|
||||
this.recentMetric = v;
|
||||
this.renderRecent(this.recentData);
|
||||
}).childNodes);
|
||||
}, _('Metric')).childNodes);
|
||||
|
||||
/* The stability story of the last day, told in one sentence and four
|
||||
* figures: what is normal, how much it wobbles, where it is now. */
|
||||
const metric = this.recentMetric || 'download_mbps';
|
||||
const st = lscommon.seriesStats(entries, metric);
|
||||
const st = lscommon.seriesStats(entries, metric, data && data.resolution);
|
||||
|
||||
if (st && st.count > 1) {
|
||||
const varPct = (st.max - st.min) / st.avg * 100,
|
||||
@@ -297,20 +298,26 @@ return view.extend({
|
||||
}
|
||||
|
||||
const fmtNum = (v, d) => (typeof v == 'number') ? v.toFixed(d) : '–';
|
||||
const when = e => {
|
||||
const d = new Date(e.timestamp);
|
||||
return isNaN(d.getTime()) ? String(e.timestamp || '?') : d.toLocaleString();
|
||||
};
|
||||
/* The shared formatter carries the date-only guard: a daily
|
||||
* aggregate's bare date fed to new Date() is read as UTC midnight
|
||||
* and shifts a calendar day in negative offsets. */
|
||||
const when = e => lscommon.chartStampFull(e, data && data.resolution);
|
||||
|
||||
const num = v => (typeof v == 'number') ? v : -1;
|
||||
/* 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) : '';
|
||||
|
||||
/* Nodes, not strings: ui.Table writes bare strings into innerHTML
|
||||
* and the server name comes from a downloaded list. */
|
||||
const cell = t => E('span', {}, [ t ]);
|
||||
this.recentTable.update(entries.slice(-recentRows).reverse().map(e => [
|
||||
[ e.epoch ?? 0, when(e) ],
|
||||
(e.server && e.server.name) || '–',
|
||||
[ num(e.download_mbps), fmtNum(e.download_mbps, 2) ],
|
||||
[ num(e.upload_mbps), fmtNum(e.upload_mbps, 2) ],
|
||||
[ num(e.ping_ms), fmtNum(e.ping_ms, 1) ],
|
||||
[ num(e.jitter_ms), fmtNum(e.jitter_ms, 1) ]
|
||||
[ e.epoch ?? 0, cell(when(e)) ],
|
||||
cell((e.server && e.server.name) || '–'),
|
||||
[ 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)) ]
|
||||
]));
|
||||
this.recentNode.appendChild(this.recentTableNode);
|
||||
|
||||
@@ -374,6 +381,17 @@ return view.extend({
|
||||
this.resultHeading.textContent = (!status.running && status.last_error)
|
||||
? _('Last successful measurement') : '';
|
||||
|
||||
/* One quiet live region, outside the tree that is rebuilt every
|
||||
* poll: announcing the rebuilt gauge SVG would read the whole dial
|
||||
* out several times a run. Set only on change, so it speaks once
|
||||
* per phase step. */
|
||||
const announce = status.running
|
||||
? (PHASES.find(p => p[0] == status.phase) || [])[3] || _('Connecting to the test server…')
|
||||
: '';
|
||||
|
||||
if (this.liveNode.textContent != announce)
|
||||
this.liveNode.textContent = announce;
|
||||
|
||||
if (status.running) {
|
||||
const rows = [];
|
||||
|
||||
@@ -385,7 +403,10 @@ return view.extend({
|
||||
PHASES.map((p, i) => E('span', {
|
||||
'class': 'librespeed-step' +
|
||||
(i == phaseIdx ? ' librespeed-step-active' :
|
||||
(phaseIdx >= 0 && i < phaseIdx ? ' librespeed-step-done' : ''))
|
||||
(phaseIdx >= 0 && i < phaseIdx ? ' librespeed-step-done' : '')),
|
||||
/* Spoken as well as dimmed: opacity is invisible to a
|
||||
* screen reader. */
|
||||
'aria-current': i == phaseIdx ? 'step' : null
|
||||
}, [ p[3] ]))));
|
||||
|
||||
/* The dial range is frozen at the start of the run -- a familiar
|
||||
@@ -404,13 +425,20 @@ return view.extend({
|
||||
rows.push(E('p', { 'class': 'spinning', 'style': 'margin-top:.75em' },
|
||||
[ _('Connecting to the test server…') ]));
|
||||
else {
|
||||
const holder = E('div', {});
|
||||
/* The dial is hidden from assistive tech -- its ten tick
|
||||
* labels are noise there -- so the one figure it carries,
|
||||
* the running rate, is repeated as plain hidden text that
|
||||
* browse mode can still reach. */
|
||||
const holder = E('div', { 'aria-hidden': 'true' });
|
||||
holder.innerHTML = gauge(
|
||||
(typeof status.mbps == 'number' && status.mbps > 0)
|
||||
? status.mbps : null,
|
||||
this.runTop,
|
||||
status.phase == 'upload' ? 1 : 0);
|
||||
rows.push(holder);
|
||||
if (typeof status.mbps == 'number' && status.mbps > 0)
|
||||
rows.push(E('span', { 'class': 'librespeed-visually-hidden' },
|
||||
[ '%.2f Mbps'.format(status.mbps) ]));
|
||||
}
|
||||
|
||||
/* Completion is a different number than the rate on the arc, so
|
||||
@@ -422,8 +450,13 @@ return view.extend({
|
||||
rows.push(E('div', { 'class': 'librespeed-muted' },
|
||||
[ overall + ' %' ]));
|
||||
|
||||
const elapsed = status.started
|
||||
? fmtElapsed(Date.now() / 1000 - status.started) : '';
|
||||
/* The backend counts on the clock that stamped the start; the
|
||||
* difference of two unrelated clocks can be negative. The old
|
||||
* subtraction stays as a fallback for an updating router. */
|
||||
const elapsed = typeof status.elapsed == 'number'
|
||||
? fmtElapsed(status.elapsed)
|
||||
: (status.started
|
||||
? fmtElapsed(Date.now() / 1000 - status.started) : '');
|
||||
|
||||
if (elapsed)
|
||||
rows.push(E('div', { 'class': 'librespeed-muted' },
|
||||
@@ -439,7 +472,15 @@ return view.extend({
|
||||
else
|
||||
this.wasRunning = false;
|
||||
|
||||
if (!status.running && status.last_error) {
|
||||
if (!status.running && status.last_error == 'stopped') {
|
||||
/* Stopping is something the user did, not something that went
|
||||
* wrong; a red banner would turn their own click into an error. */
|
||||
this.alertNode.appendChild(E('p', { 'class': 'librespeed-muted' }, [
|
||||
_('The last measurement was stopped.'),
|
||||
status.last_finished ? ' · ' + fmtTime(status.last_finished) : ''
|
||||
]));
|
||||
}
|
||||
else if (!status.running && status.last_error) {
|
||||
/* 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', {
|
||||
@@ -553,6 +594,11 @@ return view.extend({
|
||||
this.resultHeading = E('div', { 'class': 'librespeed-muted', 'style': 'margin-top:.35em' });
|
||||
this.resultNode = E('div', {});
|
||||
this.recentNode = E('div', {});
|
||||
this.liveNode = E('div', {
|
||||
'role': 'status',
|
||||
'aria-live': 'polite',
|
||||
'class': 'librespeed-visually-hidden'
|
||||
});
|
||||
this.recentCount = E('span', { 'class': 'librespeed-muted' });
|
||||
this.recentSubtitle = E('div', { 'class': 'librespeed-muted' },
|
||||
[ _('last 24 hours') ]);
|
||||
@@ -624,10 +670,15 @@ return view.extend({
|
||||
* well sit in another one, so it only formats the epoch. */
|
||||
const sched = config.schedule || {};
|
||||
const next = sched.next_runs || [];
|
||||
const schedRows = [ [ _('Enabled'), sched.enabled ? _('Yes') : _('No') ] ];
|
||||
/* 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') ] ];
|
||||
|
||||
if (sched.enabled) {
|
||||
schedRows.push([ _('Interval'), sched.interval || '1d' ]);
|
||||
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() ]);
|
||||
}
|
||||
@@ -660,6 +711,7 @@ return view.extend({
|
||||
|
||||
const main = E('div', { 'class': 'cbi-section librespeed-center', 'style': 'margin:0' }, [
|
||||
this.alertNode,
|
||||
this.liveNode,
|
||||
this.statusNode,
|
||||
this.resultHeading,
|
||||
this.resultNode,
|
||||
|
||||
Reference in new issue
Block a user