mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-13 03:45:11 +08:00
🎨 Sync 2026-08-19 20:43:52
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
Valid-License-Identifier: GPL-2.0-only
|
||||
Valid-License-Identifier: GPL-2.0
|
||||
Valid-License-Identifier: GPL-2.0-or-later
|
||||
Valid-License-Identifier: GPL-2.0+
|
||||
SPDX-URL: https://spdx.org/licenses/GPL-2.0.html
|
||||
Usage-Guide:
|
||||
To use this license in source code, put one of the following SPDX
|
||||
tag/value pairs into a comment according to the placement
|
||||
guidelines in the licensing rules documentation.
|
||||
For 'GNU General Public License (GPL) version 2 only' use:
|
||||
SPDX-License-Identifier: GPL-2.0-only
|
||||
For 'GNU General Public License (GPL) version 2 or any later version' use:
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
License-Text:
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Library General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Library General
|
||||
Public License instead of this License.
|
||||
@@ -0,0 +1,77 @@
|
||||
#
|
||||
# Copyright (C) 2026 Josef Schlehofer
|
||||
#
|
||||
# This is free software, licensed under the GNU General Public License v2.
|
||||
# See /LICENSE for more information.
|
||||
#
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=librespeed-common
|
||||
PKG_VERSION:=1.0.0
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_MAINTAINER:=Josef Schlehofer <pepe.schlehofer@gmail.com>
|
||||
PKG_LICENSE:=GPL-2.0-only
|
||||
PKG_LICENSE_FILES:=LICENSE
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/librespeed-common
|
||||
SECTION:=utils
|
||||
CATEGORY:=Utilities
|
||||
TITLE:=LibreSpeed measurement orchestration
|
||||
DEPENDS:=+librespeed-cli +rpcd-mod-ucode +jsonfilter
|
||||
PKGARCH:=all
|
||||
endef
|
||||
|
||||
define Package/librespeed-common/description
|
||||
Runs LibreSpeed measurements from UCI configuration: a single entry point
|
||||
used both interactively and from cron, a schedule kept in step with UCI by
|
||||
the init script, and results recorded as JSON for anything that wants to
|
||||
read them, such as luci-app-librespeed, and a ubus interface for anything else.
|
||||
endef
|
||||
|
||||
define Package/librespeed-common/conffiles
|
||||
/etc/config/librespeed
|
||||
endef
|
||||
|
||||
# rpcd only enumerates its plugin directory at startup, so the librespeed ubus
|
||||
# object appears (and disappears) with a reload, not with the file.
|
||||
define Package/librespeed-common/postinst
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
|
||||
exit 0
|
||||
endef
|
||||
|
||||
define Package/librespeed-common/postrm
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] || /etc/init.d/rpcd reload
|
||||
exit 0
|
||||
endef
|
||||
|
||||
# No source archive to unpack: the license text ships beside the Makefile and
|
||||
# is staged so PKG_LICENSE_FILES points at a real file.
|
||||
define Build/Prepare
|
||||
$(INSTALL_DATA) ./LICENSE $(PKG_BUILD_DIR)/
|
||||
endef
|
||||
|
||||
define Build/Compile
|
||||
endef
|
||||
|
||||
define Package/librespeed-common/install
|
||||
$(INSTALL_DIR) $(1)/usr/libexec
|
||||
$(INSTALL_BIN) ./files/librespeed-run $(1)/usr/libexec/librespeed-run
|
||||
$(INSTALL_BIN) ./files/librespeed-aggregate $(1)/usr/libexec/librespeed-aggregate
|
||||
$(SED) 's/%%VERSION%%/$(PKG_VERSION)-$(PKG_RELEASE)/g' \
|
||||
$(1)/usr/libexec/librespeed-run \
|
||||
$(1)/usr/libexec/librespeed-aggregate
|
||||
$(INSTALL_DIR) $(1)/usr/share/rpcd/ucode
|
||||
$(INSTALL_DATA) ./files/librespeed.uc $(1)/usr/share/rpcd/ucode/librespeed.uc
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(INSTALL_CONF) ./files/librespeed.config $(1)/etc/config/librespeed
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) ./files/librespeed.init $(1)/etc/init.d/librespeed
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,librespeed-common))
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env ucode
|
||||
// Reduces completed days of raw measurement history to one line per day --
|
||||
// min/avg/max over that day's samples, not a measurement itself -- and
|
||||
// appends them to the archive on persistent storage. Runs from cron shortly
|
||||
// after midnight; librespeed.init keeps that entry in step with UCI.
|
||||
//
|
||||
// Only days strictly before today are archived: a day's aggregate is written
|
||||
// once and never revisited, which is what makes reruns idempotent without any
|
||||
// marker file -- a day already present in the archive is simply skipped.
|
||||
// Today's raw measurements stay in RAM only; if power is lost they are gone,
|
||||
// which the Settings page says out loud.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { open, readfile, writefile, rename, mkdir } from 'fs';
|
||||
|
||||
// Packaging checks probe every executable for these.
|
||||
if (length(ARGV) > 0) {
|
||||
if (ARGV[0] == '--version') {
|
||||
print("librespeed-common %%VERSION%%\n");
|
||||
exit(0);
|
||||
}
|
||||
print("Usage: librespeed-aggregate\n" +
|
||||
"Reduces completed days of measurement history to daily min/avg/max\n" +
|
||||
"aggregates. Runs from cron; takes no arguments.\n");
|
||||
exit(0);
|
||||
}
|
||||
import { cursor } from 'uci';
|
||||
|
||||
const METRICS = [ 'download_mbps', 'upload_mbps', 'ping_ms', 'jitter_ms' ];
|
||||
|
||||
const uci = cursor();
|
||||
|
||||
function conf(section, option, fallback) {
|
||||
const v = uci.get('librespeed', section, option);
|
||||
|
||||
return (v == null || v == '') ? fallback : v;
|
||||
}
|
||||
|
||||
if (conf('history', 'enabled', '1') == '0')
|
||||
exit(0);
|
||||
|
||||
const raw_path = conf('history', 'path', '/tmp/librespeed/history.jsonl');
|
||||
const archive_path = conf('history', 'archive_path', '');
|
||||
const archive_days = int(conf('history', 'archive_retention', '365d')) || 365;
|
||||
|
||||
if (archive_path == '')
|
||||
exit(0);
|
||||
|
||||
function read_lines(path) {
|
||||
const out = [];
|
||||
const f = open(path, 'r');
|
||||
|
||||
if (!f)
|
||||
return out;
|
||||
|
||||
for (let line = f.read('line'); length(line); line = f.read('line')) {
|
||||
try {
|
||||
push(out, json(line));
|
||||
}
|
||||
catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
f.close();
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function day_key(epoch) {
|
||||
const lt = localtime(epoch);
|
||||
|
||||
return sprintf('%04d-%02d-%02d', lt.year, lt.mon, lt.mday);
|
||||
}
|
||||
|
||||
function day_start(key) {
|
||||
const p = split(key, '-');
|
||||
|
||||
return timelocal({
|
||||
year: int(p[0]), mon: int(p[1]), mday: int(p[2]),
|
||||
hour: 0, min: 0, sec: 0
|
||||
});
|
||||
}
|
||||
|
||||
function round2(v) {
|
||||
return int(v * 100 + 0.5) / 100.0;
|
||||
}
|
||||
|
||||
const today = day_key(time());
|
||||
|
||||
// Which days the archive already holds. Entries carry the day in `timestamp`.
|
||||
const archive = read_lines(archive_path);
|
||||
const have = {};
|
||||
|
||||
for (let e in archive)
|
||||
have[e.timestamp] = true;
|
||||
|
||||
// Group raw lines by local calendar day, completed days only.
|
||||
const days = {};
|
||||
|
||||
for (let e in read_lines(raw_path)) {
|
||||
const epoch = int(e?.epoch ?? 0);
|
||||
|
||||
if (!epoch)
|
||||
continue;
|
||||
|
||||
const key = day_key(epoch);
|
||||
|
||||
if (key >= today || have[key])
|
||||
continue;
|
||||
|
||||
days[key] = days[key] ?? [];
|
||||
push(days[key], e);
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
|
||||
for (let key in sort(keys(days))) {
|
||||
const entry = {
|
||||
timestamp: key,
|
||||
epoch: day_start(key),
|
||||
samples: length(days[key])
|
||||
};
|
||||
|
||||
for (let m in METRICS) {
|
||||
let lo = null, hi = null, sum = 0.0, n = 0;
|
||||
|
||||
for (let e in days[key]) {
|
||||
const v = e[m];
|
||||
|
||||
if (type(v) != 'double' && type(v) != 'int')
|
||||
continue;
|
||||
|
||||
lo = (lo == null || v < lo) ? v : lo;
|
||||
hi = (hi == null || v > hi) ? v : hi;
|
||||
sum += v;
|
||||
n++;
|
||||
}
|
||||
|
||||
if (n > 0) {
|
||||
// The mean lives in the plain field so a consumer that only knows
|
||||
// raw entries keeps working; min and max sit beside it.
|
||||
entry[m] = round2(sum / n);
|
||||
entry[`${m}_min`] = lo;
|
||||
entry[`${m}_max`] = hi;
|
||||
}
|
||||
}
|
||||
|
||||
push(archive, entry);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Archive retention: integer comparison on the day-start epoch.
|
||||
const cutoff = time() - archive_days * 86400;
|
||||
const kept = filter(archive, e => int(e?.epoch ?? 0) >= cutoff);
|
||||
|
||||
if (length(kept) != length(archive))
|
||||
changed = true;
|
||||
|
||||
if (!changed)
|
||||
exit(0);
|
||||
|
||||
let tmp = `${archive_path}.tmp`;
|
||||
let out = '';
|
||||
|
||||
for (let e in sort(kept, (a, b) => int(a.epoch) - int(b.epoch)))
|
||||
out += sprintf('%J\n', e);
|
||||
|
||||
// The last component only, never the whole tree: archive_path commonly
|
||||
// points at external storage, and with the mount down a recursive mkdir
|
||||
// would build the path on the overlay and write every night's aggregate to
|
||||
// internal flash, to be shadowed once the disk is back. Failing here leaves
|
||||
// the location as the user prepared it.
|
||||
const dir = replace(archive_path, /\/[^\/]+$/, '');
|
||||
if (dir != '' && dir != archive_path)
|
||||
mkdir(dir, 0o755);
|
||||
|
||||
// Atomic: a reader never sees a half-written archive. A failed write goes to
|
||||
// syslog: this runs from cron, where stderr has nowhere to go.
|
||||
if (writefile(tmp, out) != null)
|
||||
rename(tmp, archive_path);
|
||||
else
|
||||
system(['logger', '-t', 'librespeed', `aggregate: cannot write ${tmp}`]);
|
||||
Executable
+396
@@ -0,0 +1,396 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Runs one LibreSpeed measurement and records the result.
|
||||
#
|
||||
# The only thing that starts a measurement: LuCI reaches it through rpcd, the
|
||||
# scheduler calls it directly. Holding the lock for the whole run means a second
|
||||
# invocation from either side fails instead of measuring against the first.
|
||||
|
||||
# Packaging checks probe every executable for these; a runner that ignored
|
||||
# them would start a measurement instead of answering.
|
||||
case "$1" in
|
||||
--version)
|
||||
echo "librespeed-common %%VERSION%%"
|
||||
exit 0
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<'EOF'
|
||||
Usage: librespeed-run
|
||||
Runs one LibreSpeed measurement according to /etc/config/librespeed and
|
||||
records the result for the ubus interface. Started by LuCI or cron; takes
|
||||
no arguments.
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
. /lib/functions.sh
|
||||
. /lib/functions/network.sh
|
||||
. /usr/share/libubox/jshn.sh
|
||||
|
||||
CLI=/usr/bin/librespeed-cli
|
||||
STATE_DIR=/tmp/librespeed
|
||||
STATE="$STATE_DIR/state.json"
|
||||
RESULT="$STATE_DIR/result.json"
|
||||
LOCK=/var/lock/librespeed.lock
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# Writes $2 to $1 without ever leaving a half-written file for a reader.
|
||||
atomic_write() {
|
||||
local target="$1" tmp="$1.$$"
|
||||
printf '%s\n' "$2" > "$tmp" && mv "$tmp" "$target"
|
||||
}
|
||||
|
||||
write_state() {
|
||||
json_init
|
||||
json_add_boolean running "$1"
|
||||
[ -n "$2" ] && json_add_int pid "$2"
|
||||
[ -n "$3" ] && json_add_int started "$3"
|
||||
[ -n "$4" ] && json_add_int last_finished "$4"
|
||||
json_add_string last_error "${5:-}"
|
||||
json_add_string phase "${6:-}"
|
||||
[ -n "$7" ] && json_add_double mbps "$7"
|
||||
[ -n "$8" ] && json_add_int progress "$8"
|
||||
atomic_write "$STATE" "$(json_dump)"
|
||||
}
|
||||
|
||||
# Approximates progress from the interface byte counters, one sample a second.
|
||||
#
|
||||
# The fallback narrator for clients that cannot stream: it reads the whole
|
||||
# interface, not the test, so it is honest only while the test dominates the
|
||||
# link -- which is exactly the situation a progress line describes. Whichever
|
||||
# direction carries the traffic names the phase.
|
||||
sampler() {
|
||||
local dev="$1" rx tx prx ptx phase mbps
|
||||
[ -r "/sys/class/net/$dev/statistics/rx_bytes" ] || return 0
|
||||
prx=$(cat "/sys/class/net/$dev/statistics/rx_bytes")
|
||||
ptx=$(cat "/sys/class/net/$dev/statistics/tx_bytes")
|
||||
while :; do
|
||||
sleep 1
|
||||
rx=$(cat "/sys/class/net/$dev/statistics/rx_bytes" 2>/dev/null) || return 0
|
||||
tx=$(cat "/sys/class/net/$dev/statistics/tx_bytes" 2>/dev/null) || return 0
|
||||
set -- $(awk -v rx="$rx" -v prx="$prx" -v tx="$tx" -v ptx="$ptx" 'BEGIN {
|
||||
drx = (rx - prx) * 8 / 1000000
|
||||
dtx = (tx - ptx) * 8 / 1000000
|
||||
if (drx >= dtx && drx > 1) printf "download %.1f", drx
|
||||
else if (dtx > 1) printf "upload %.1f", dtx
|
||||
else printf "- -"
|
||||
}')
|
||||
prx=$rx; ptx=$tx
|
||||
if [ "$1" = "-" ]; then
|
||||
write_state 1 $$ "$started" "" "" "" ""
|
||||
else
|
||||
write_state 1 $$ "$started" "" "" "$1" "$2"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
fail() {
|
||||
# A run that failed with the cached server drops the cache: the next run
|
||||
# rediscovers instead of failing against the same dead choice forever.
|
||||
[ "${used_cache:-0}" = 1 ] && rm -f "$LIST_CACHE" "$LIST_CACHE.src" "$CHOICE_CACHE"
|
||||
write_state 0 "" "" "$(date +%s)" "$1"
|
||||
logger -t librespeed "measurement failed: $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# One measurement at a time. The descriptor stays open for the whole run, so the
|
||||
# kernel releases the lock even if this script is killed -- there is no stale
|
||||
# state to time out.
|
||||
exec 9>"$LOCK"
|
||||
flock -n 9 || {
|
||||
echo "already running" >&2
|
||||
exit 3
|
||||
}
|
||||
|
||||
config_load librespeed
|
||||
config_get iface main interface wan
|
||||
config_get server main server auto
|
||||
config_get scheme main scheme auto
|
||||
config_get server_list main server_list ''
|
||||
config_get_bool hist_enabled history enabled 1
|
||||
config_get hist_path history path "$STATE_DIR/history.jsonl"
|
||||
config_get hist_retention history retention 30d
|
||||
|
||||
# --interface takes a device, and UCI carries a logical interface name.
|
||||
dev=""
|
||||
network_get_device dev "$iface" 2>/dev/null
|
||||
|
||||
[ -x "$CLI" ] || fail "librespeed-cli is not installed"
|
||||
|
||||
# The Rust client reports progress as NDJSON under --json-stream; the Go one
|
||||
# does not have the flag yet. Asking --help is one extra exec per measurement
|
||||
# and keeps one script driving either client.
|
||||
stream=0
|
||||
if "$CLI" --help 2>&1 | grep -q -- '--json-stream'; then
|
||||
stream=1
|
||||
set -- "$CLI" --json-stream
|
||||
else
|
||||
set -- "$CLI" --json
|
||||
fi
|
||||
# The router-side counterpart of the web UI remembering its chosen server:
|
||||
# after an automatic run the picked server's id and the downloaded list are
|
||||
# cached, and later runs go straight to the same server with --server and
|
||||
# --local-json instead of fetching the list and pinging everything on it.
|
||||
LIST_CACHE="$STATE_DIR/servers.json"
|
||||
CHOICE_CACHE="$STATE_DIR/server-choice"
|
||||
CACHE_TTL=86400
|
||||
# Where the list comes from: a self-hosted deployment -- Turris runs
|
||||
# https://librespeed.turris.cz/servers.json -- replaces the official one.
|
||||
LIST_URL="${server_list:-https://librespeed.org/backend-servers/servers.php}"
|
||||
|
||||
cache_fresh=0
|
||||
c_id=""
|
||||
if [ -s "$LIST_CACHE" ] && [ -s "$CHOICE_CACHE" ]; then
|
||||
read -r c_epoch c_id < "$CHOICE_CACHE"
|
||||
case "$c_id" in *[!0-9]*) c_id="" ;; esac
|
||||
[ -n "$c_epoch" ] && [ $(( $(date +%s) - c_epoch )) -lt "$CACHE_TTL" ] || c_id=""
|
||||
# A cache fetched from another list is no cache at all.
|
||||
[ "$(cat "$LIST_CACHE.src" 2>/dev/null)" = "$LIST_URL" ] || c_id=""
|
||||
fi
|
||||
[ -n "$c_id" ] && cache_fresh=1
|
||||
|
||||
used_cache=0
|
||||
if [ "$server" != "auto" ]; then
|
||||
set -- "$@" --server "$server"
|
||||
# An explicit id still needs the list to resolve it; the cached copy
|
||||
# saves that download too.
|
||||
if [ "$cache_fresh" = 1 ]; then
|
||||
set -- "$@" --local-json "$LIST_CACHE"
|
||||
used_cache=1
|
||||
elif [ -n "$server_list" ]; then
|
||||
set -- "$@" --server-json "$LIST_URL"
|
||||
fi
|
||||
elif [ "$cache_fresh" = 1 ]; then
|
||||
set -- "$@" --server "$c_id" --local-json "$LIST_CACHE"
|
||||
used_cache=1
|
||||
elif [ -n "$server_list" ]; then
|
||||
set -- "$@" --server-json "$LIST_URL"
|
||||
fi
|
||||
[ -n "$dev" ] && set -- "$@" --interface "$dev"
|
||||
# TLS itself can bound the result on routers without AES acceleration, so the
|
||||
# scheme is a measurement setting, not just a transport detail.
|
||||
case "$scheme" in
|
||||
https) set -- "$@" --secure ;;
|
||||
http) set -- "$@" --insecure ;;
|
||||
esac
|
||||
# Never --bytes: it switches the report to MB/s and the history would end up
|
||||
# holding two units that cannot be told apart afterwards.
|
||||
|
||||
started=$(date +%s)
|
||||
write_state 1 $$ "$started"
|
||||
|
||||
# stop kills our whole process group, so librespeed-cli dies with us; ash runs
|
||||
# this trap once the foreground child has exited, and it records that the run
|
||||
# was stopped rather than pretending the measurement failed.
|
||||
trap 'write_state 0 "" "" "$(date +%s)" "stopped"; exit 1' TERM
|
||||
|
||||
if [ "$stream" = 1 ]; then
|
||||
# The while runs in a subshell, so the reports cannot come back in a
|
||||
# variable; they land in a file instead. State updates are throttled to
|
||||
# nothing -- one arrives a second and state.json lives in tmpfs.
|
||||
rm -f "$STATE_DIR/reports.json"
|
||||
"$@" 2>"$STATE_DIR/stderr.log" < /dev/null | while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*'"event":"result"'*)
|
||||
printf '%s' "$line" | jsonfilter -e '@.reports' \
|
||||
> "$STATE_DIR/reports.json" 2>/dev/null
|
||||
;;
|
||||
*'"event":"progress"'*)
|
||||
write_state 1 $$ "$started" "" "" \
|
||||
"$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" \
|
||||
"$(printf '%s' "$line" | jsonfilter -e '@.mbps' 2>/dev/null)" \
|
||||
"$(printf '%s' "$line" | jsonfilter -e '@.progress' 2>/dev/null)"
|
||||
;;
|
||||
*'"event":"phase"'*)
|
||||
write_state 1 $$ "$started" "" "" \
|
||||
"$(printf '%s' "$line" | jsonfilter -e '@.phase' 2>/dev/null)" ""
|
||||
;;
|
||||
esac
|
||||
done
|
||||
# The pipeline's status is the reader's, so success is judged by what the
|
||||
# stream delivered: a client that failed never emitted a result event.
|
||||
out=$(cat "$STATE_DIR/reports.json" 2>/dev/null)
|
||||
rm -f "$STATE_DIR/reports.json"
|
||||
[ -n "$out" ] || \
|
||||
fail "$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null || echo 'measurement failed')"
|
||||
else
|
||||
sampler_pid=""
|
||||
if [ -n "$dev" ]; then
|
||||
sampler "$dev" &
|
||||
sampler_pid=$!
|
||||
fi
|
||||
out=$("$@" 2>"$STATE_DIR/stderr.log" < /dev/null); rc=$?
|
||||
[ -n "$sampler_pid" ] && kill "$sampler_pid" 2>/dev/null
|
||||
[ "$rc" = 0 ] || \
|
||||
fail "$(tail -n 1 "$STATE_DIR/stderr.log" 2>/dev/null || echo 'measurement failed')"
|
||||
fi
|
||||
|
||||
finished=$(date +%s)
|
||||
|
||||
[ -n "$out" ] || fail "no output from librespeed-cli"
|
||||
# Both clients print an array of reports, one per server tested: the Go
|
||||
# client marshals []report.JSONReport, and the Rust port mirrors that shape.
|
||||
# jshn cannot load a bare array -- blobmsg wants an object at the top -- so the
|
||||
# report array is wrapped before parsing. Found the hard way on a router: this
|
||||
# is exactly the step no macOS test could reach.
|
||||
json_load "{ \"reports\": $out }" 2>/dev/null || fail "unparseable output from librespeed-cli"
|
||||
json_select reports 2>/dev/null || fail "unparseable output from librespeed-cli"
|
||||
|
||||
# The report is an array with one entry per server tested.
|
||||
json_select 1 2>/dev/null || fail "empty report"
|
||||
|
||||
json_get_var ts timestamp
|
||||
json_get_var ping ping
|
||||
json_get_var jitter jitter
|
||||
json_get_var download download
|
||||
json_get_var upload upload
|
||||
json_get_var bsent bytes_sent
|
||||
json_get_var brecv bytes_received
|
||||
json_get_var share share
|
||||
|
||||
srv_id=""; srv_name=""; srv_url=""
|
||||
if json_select server 2>/dev/null; then
|
||||
json_get_var srv_id id
|
||||
json_get_var srv_name name
|
||||
json_get_var srv_url url
|
||||
json_select ..
|
||||
fi
|
||||
|
||||
cli_ip=""; cli_org=""
|
||||
if json_select client 2>/dev/null; then
|
||||
json_get_var cli_ip ip
|
||||
json_get_var cli_org org
|
||||
json_select ..
|
||||
fi
|
||||
|
||||
# The family of the address the backend saw is the family the test ran over.
|
||||
# Derived here rather than asked of the CLI, so it works with any client; a
|
||||
# redacted or missing address simply leaves the field out.
|
||||
family=""
|
||||
case "$cli_ip" in
|
||||
*:*) family="ipv6" ;;
|
||||
*.*) family="ipv4" ;;
|
||||
esac
|
||||
|
||||
# Whether the run was encrypted is visible from the URL the CLI settled on;
|
||||
# stored per measurement because the scheme option can change between runs.
|
||||
proto=""
|
||||
case "$srv_url" in
|
||||
https:*) proto="https" ;;
|
||||
http:*) proto="http" ;;
|
||||
esac
|
||||
|
||||
# result.json -- the last completed measurement, not a database.
|
||||
json_init
|
||||
json_add_string timestamp "$ts"
|
||||
json_add_int started "$started"
|
||||
json_add_int finished "$finished"
|
||||
json_add_string interface "$iface"
|
||||
json_add_object server
|
||||
# Absent until the CLI reports it; consumers treat null as unknown.
|
||||
[ -n "$srv_id" ] && json_add_int id "$srv_id"
|
||||
json_add_string name "$srv_name"
|
||||
json_add_string url "$srv_url"
|
||||
json_close_object
|
||||
json_add_object client
|
||||
json_add_string ip "$cli_ip"
|
||||
json_add_string org "$cli_org"
|
||||
json_close_object
|
||||
[ -n "$family" ] && json_add_string family "$family"
|
||||
[ -n "$proto" ] && json_add_string proto "$proto"
|
||||
json_add_double download_mbps "$download"
|
||||
json_add_double upload_mbps "$upload"
|
||||
json_add_double ping_ms "$ping"
|
||||
json_add_double jitter_ms "$jitter"
|
||||
json_add_int bytes_sent "$bsent"
|
||||
json_add_int bytes_received "$brecv"
|
||||
json_add_string share "$share"
|
||||
result="$(json_dump)"
|
||||
atomic_write "$RESULT" "$result"
|
||||
|
||||
if [ "$hist_enabled" = "1" ]; then
|
||||
mkdir -p "$(dirname "$hist_path")"
|
||||
|
||||
json_init
|
||||
json_add_string timestamp "$ts"
|
||||
json_add_int epoch "$finished"
|
||||
json_add_string interface "$iface"
|
||||
json_add_object server
|
||||
[ -n "$srv_id" ] && json_add_int id "$srv_id"
|
||||
json_add_string name "$srv_name"
|
||||
json_add_string url "$srv_url"
|
||||
json_close_object
|
||||
[ -n "$family" ] && json_add_string family "$family"
|
||||
[ -n "$proto" ] && json_add_string proto "$proto"
|
||||
json_add_double download_mbps "$download"
|
||||
json_add_double upload_mbps "$upload"
|
||||
json_add_double ping_ms "$ping"
|
||||
json_add_double jitter_ms "$jitter"
|
||||
printf '%s\n' "$(json_dump)" >> "$hist_path"
|
||||
|
||||
# Retention. The epoch above makes this an integer comparison, so no date(1)
|
||||
# runs here -- a year of history is thousands of lines and forking once per
|
||||
# line is not something a router should be asked to do.
|
||||
days=${hist_retention%d}
|
||||
case "$days" in
|
||||
''|*[!0-9]*) days=0 ;;
|
||||
esac
|
||||
if [ "$days" -gt 0 ]; then
|
||||
cutoff=$(( finished - days * 86400 ))
|
||||
|
||||
# Compacting rewrites the whole file, so it happens in batches rather
|
||||
# than whenever a single line falls out. Past the retention window every
|
||||
# run expires something, and rewriting on each of them would push about
|
||||
# a gigabyte a year through the flash instead of a few megabytes. The
|
||||
# file therefore holds somewhat more than the window, and is trimmed
|
||||
# once enough has accumulated to be worth the write.
|
||||
expired=$(awk -v c="$cutoff" '
|
||||
match($0, /"epoch":[0-9]+/) {
|
||||
if (substr($0, RSTART + 8, RLENGTH - 8) + 0 < c) n++
|
||||
}
|
||||
END { print n + 0 }
|
||||
' "$hist_path")
|
||||
total=$(wc -l < "$hist_path")
|
||||
|
||||
if [ "$expired" -ge 50 ] || [ "$expired" -ge $(( total / 2 )) ] && [ "$expired" -gt 0 ]; then
|
||||
tmp="$hist_path.$$"
|
||||
awk -v c="$cutoff" '
|
||||
match($0, /"epoch":[0-9]+/) {
|
||||
if (substr($0, RSTART + 8, RLENGTH - 8) + 0 < c) next
|
||||
}
|
||||
{ print }
|
||||
' "$hist_path" > "$tmp" && mv "$tmp" "$hist_path"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
write_state 0 "" "" "$finished" ""
|
||||
|
||||
# Refresh the server cache after the run, so the next one starts instantly:
|
||||
# at most one list download a day, and the choice is the server this run
|
||||
# actually used, looked up by the name the report carries.
|
||||
if [ "$cache_fresh" = 0 ]; then
|
||||
if uclient-fetch -q -T 15 -O "$LIST_CACHE.tmp" "$LIST_URL" 2>/dev/null \
|
||||
&& [ -s "$LIST_CACHE.tmp" ]; then
|
||||
mv "$LIST_CACHE.tmp" "$LIST_CACHE"
|
||||
printf '%s\n' "$LIST_URL" > "$LIST_CACHE.src"
|
||||
else
|
||||
rm -f "$LIST_CACHE.tmp"
|
||||
fi
|
||||
if [ -s "$LIST_CACHE" ] && [ -n "$srv_name" ]; then
|
||||
new_id=$(LIST="$LIST_CACHE" NAME="$srv_name" ucode -e '
|
||||
let fs = require("fs");
|
||||
let list = json(fs.readfile(getenv("LIST")) || "[]");
|
||||
for (s in list)
|
||||
if (s.name == getenv("NAME")) { print(s.id); break; }
|
||||
' 2>/dev/null)
|
||||
case "$new_id" in
|
||||
''|*[!0-9]*) ;;
|
||||
*) printf '%s %s\n' "$(date +%s)" "$new_id" > "$CHOICE_CACHE" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
logger -t librespeed "measurement done: ${download} Mbps down, ${upload} Mbps up"
|
||||
exit 0
|
||||
@@ -0,0 +1,18 @@
|
||||
config librespeed 'main'
|
||||
option interface 'wan'
|
||||
option server 'auto'
|
||||
option scheme 'auto'
|
||||
option server_list ''
|
||||
|
||||
config librespeed 'schedule'
|
||||
option enabled '0'
|
||||
option interval '1d'
|
||||
option days '*'
|
||||
option hours '2-5'
|
||||
|
||||
config librespeed 'history'
|
||||
option enabled '1'
|
||||
option path '/tmp/librespeed/history.jsonl'
|
||||
option retention '30d'
|
||||
option archive_path ''
|
||||
option archive_retention '365d'
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# Keeps the scheduled-measurement cron entry in step with UCI. There is no
|
||||
# daemon here: measurements are one-shot runs of librespeed-run, and the rpcd
|
||||
# interface needs no service of its own.
|
||||
|
||||
START=95
|
||||
USE_PROCD=1
|
||||
|
||||
CRONTAB=/etc/crontabs/root
|
||||
RUN=/usr/libexec/librespeed-run
|
||||
AGG=/usr/libexec/librespeed-aggregate
|
||||
|
||||
# Builds the cron time fields from UCI. For sub-daily intervals `hours`
|
||||
# restricts when measurements may run; for a daily one it is the window a
|
||||
# random time is drawn from, drawn here at sync time. That keeps the herd
|
||||
# apart -- every router lands on its own minute -- without any process
|
||||
# sleeping through the night, at the cost of re-drawing on every reload.
|
||||
cron_expr() {
|
||||
local interval="$1" days="$2" hours="$3"
|
||||
local start end m h v
|
||||
|
||||
case "$interval" in
|
||||
*m)
|
||||
v=${interval%m}
|
||||
case "$v" in ''|0|*[!0-9]*) return 1 ;; esac
|
||||
[ "$v" -le 59 ] || return 1
|
||||
echo "*/$v ${hours:-*} * * $days"
|
||||
;;
|
||||
*h)
|
||||
v=${interval%h}
|
||||
case "$v" in ''|0|*[!0-9]*) return 1 ;; esac
|
||||
[ "$v" -le 23 ] || return 1
|
||||
if [ -n "$hours" ]; then
|
||||
echo "0 $hours/$v * * $days"
|
||||
else
|
||||
echo "0 */$v * * $days"
|
||||
fi
|
||||
;;
|
||||
1d|24h)
|
||||
start=${hours%-*}; end=${hours#*-}
|
||||
[ -n "$hours" ] || { start=0; end=23; }
|
||||
# A window wrapping midnight (22-4) is not supported; treat it as the
|
||||
# single starting hour rather than guessing.
|
||||
[ "$end" -ge "$start" ] 2>/dev/null || end=$start
|
||||
# Time xor pid as the seed: seconds-granular srand() alone would give
|
||||
# two syncs in the same second the same draw.
|
||||
set -- $(awk -v a="$start" -v b="$end" -v s="$(( $(date +%s) ^ $$ ))" 'BEGIN {
|
||||
srand(s)
|
||||
printf "%d %d", int(rand() * 60), a + int(rand() * (b - a + 1))
|
||||
}')
|
||||
echo "$1 $2 * * $days"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
restart_cron() {
|
||||
# Unconditional: on a stock system the crontab dir starts empty, so crond
|
||||
# is not running at all -- restart starts a stopped procd service, and a
|
||||
# guard on "running" would mean the schedule never fires until reboot.
|
||||
/etc/init.d/cron restart >/dev/null 2>&1
|
||||
return 0
|
||||
}
|
||||
|
||||
sync_cron() {
|
||||
local enabled interval days hours expr hist_enabled archive_path
|
||||
|
||||
config_load librespeed
|
||||
config_get_bool enabled schedule enabled 0
|
||||
config_get interval schedule interval 1d
|
||||
config_get days schedule days '*'
|
||||
config_get hours schedule hours ''
|
||||
config_get_bool hist_enabled history enabled 1
|
||||
config_get archive_path history archive_path ''
|
||||
|
||||
# These end up verbatim in root's crontab, so anything unexpected becomes
|
||||
# the harmless default rather than a cron field it was not meant to be.
|
||||
case "$days" in *[!0-9,-]*) days='*' ;; '') days='*' ;; esac
|
||||
case "$hours" in *[!0-9-]*) hours='' ;; esac
|
||||
|
||||
mkdir -p "${CRONTAB%/*}"
|
||||
touch "$CRONTAB"
|
||||
sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB"
|
||||
|
||||
# The day completes at midnight; five past, it is reduced to one archive
|
||||
# line. A missed run costs nothing to catch up: raw lives in RAM, so after
|
||||
# a power cut there is nothing left to aggregate anyway.
|
||||
if [ "$hist_enabled" = 1 ] && [ -n "$archive_path" ]; then
|
||||
echo "5 0 * * * $AGG" >> "$CRONTAB"
|
||||
fi
|
||||
|
||||
if [ "$enabled" = 1 ]; then
|
||||
if expr=$(cron_expr "$interval" "$days" "$hours"); then
|
||||
echo "$expr $RUN" >> "$CRONTAB"
|
||||
else
|
||||
logger -t librespeed "unsupported schedule interval '$interval'"
|
||||
fi
|
||||
fi
|
||||
|
||||
restart_cron
|
||||
}
|
||||
|
||||
start_service() {
|
||||
sync_cron
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
sed -i "\\#$RUN#d;\\#$AGG#d" "$CRONTAB"
|
||||
restart_cron
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "librespeed"
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
sync_cron
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env ucode
|
||||
// ubus interface for LibreSpeed measurements.
|
||||
//
|
||||
// Runs inside rpcd, which is exactly why it never measures anything itself: a
|
||||
// measurement takes tens of seconds and would stall rpcd's event loop -- and
|
||||
// with it every rpcd consumer on the system. Anything long-lived is handed to
|
||||
// librespeed-run as a detached process.
|
||||
|
||||
'use strict';
|
||||
|
||||
import { open, readfile, stat } from 'fs';
|
||||
import { cursor } from 'uci';
|
||||
|
||||
const STATE_DIR = '/tmp/librespeed';
|
||||
const STATE = `${STATE_DIR}/state.json`;
|
||||
const RESULT = `${STATE_DIR}/result.json`;
|
||||
const LOCK = '/var/lock/librespeed.lock';
|
||||
const RUN = '/usr/libexec/librespeed-run';
|
||||
|
||||
function read_json(path) {
|
||||
const text = readfile(path);
|
||||
|
||||
if (text == null)
|
||||
return null;
|
||||
|
||||
let value = null;
|
||||
|
||||
try {
|
||||
value = json(text);
|
||||
}
|
||||
catch (e) {
|
||||
value = null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// The process group of a pid, read from /proc: the runner is spawned detached
|
||||
// (start-stop-daemon -b or setsid), so its group holds the whole measurement
|
||||
// tree and nothing else. Parsed after the comm field's closing parenthesis,
|
||||
// the one place a process can put spaces.
|
||||
function pgid_of(pid) {
|
||||
const st = readfile(`/proc/${pid}/stat`);
|
||||
|
||||
if (!st)
|
||||
return 0;
|
||||
|
||||
const f = split(trim(substr(st, rindex(st, ')') + 1)), ' ');
|
||||
|
||||
return int(f[2] ?? 0);
|
||||
}
|
||||
|
||||
// The lock says whether a measurement runs, not a field in a file: a process
|
||||
// that dies takes its lock with it, so there is no stale state to age out.
|
||||
function is_running() {
|
||||
const f = open(LOCK, 'r');
|
||||
|
||||
if (!f)
|
||||
return false;
|
||||
|
||||
const acquired = f.lock('xn');
|
||||
|
||||
if (acquired)
|
||||
f.lock('u');
|
||||
|
||||
f.close();
|
||||
|
||||
return !acquired;
|
||||
}
|
||||
|
||||
function config_get(uci, section, option, fallback) {
|
||||
const v = uci.get('librespeed', section, option);
|
||||
|
||||
return (v == null || v == '') ? fallback : v;
|
||||
}
|
||||
|
||||
// Next occurrences of the drawn cron line, computed here rather than in the
|
||||
// browser: the schedule fires in the router's timezone, and the browser may
|
||||
// well sit in another one. Understands only the shapes librespeed.init
|
||||
// emits: numbers, ranges, ranges with a step, star, and comma lists.
|
||||
function cron_next(line, count) {
|
||||
const f = split(trim(line ?? ''), /\s+/);
|
||||
|
||||
if (length(f) < 5)
|
||||
return [];
|
||||
|
||||
const match_field = function(pat, val) {
|
||||
for (let part in split(pat, ',')) {
|
||||
let step = 1;
|
||||
let m = match(part, /^(.+)\/([0-9]+)$/);
|
||||
|
||||
if (m) {
|
||||
part = m[1];
|
||||
step = int(m[2]);
|
||||
}
|
||||
|
||||
let a, b;
|
||||
|
||||
if (part == '*') {
|
||||
a = 0;
|
||||
b = 59;
|
||||
}
|
||||
else {
|
||||
m = match(part, /^([0-9]+)-([0-9]+)$/);
|
||||
if (m) {
|
||||
a = int(m[1]);
|
||||
b = int(m[2]);
|
||||
}
|
||||
else {
|
||||
a = int(part);
|
||||
b = a;
|
||||
}
|
||||
}
|
||||
|
||||
if (val >= a && val <= b && (val - a) % step == 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const out = [];
|
||||
let t = time();
|
||||
t -= t % 60;
|
||||
|
||||
for (let i = 0; i < 8 * 24 * 60 && length(out) < count; i++) {
|
||||
t += 60;
|
||||
const lt = localtime(t);
|
||||
|
||||
// % 7 folds both weekday conventions onto cron's 0-6 with Sunday 0.
|
||||
if (match_field(f[0], lt.min) && match_field(f[1], lt.hour) &&
|
||||
match_field(f[4], lt.wday % 7))
|
||||
push(out, t);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const methods = {
|
||||
start: {
|
||||
call: function() {
|
||||
if (is_running())
|
||||
return { error: 'already running' };
|
||||
|
||||
if (!stat(RUN))
|
||||
return { error: 'not installed' };
|
||||
|
||||
// Detached: the frontend polls status instead of waiting here.
|
||||
system(`start-stop-daemon -S -b -x ${RUN} >/dev/null 2>&1 || ( setsid ${RUN} >/dev/null 2>&1 & )`);
|
||||
|
||||
return { started: true };
|
||||
}
|
||||
},
|
||||
|
||||
stop: {
|
||||
call: function() {
|
||||
const st = read_json(STATE);
|
||||
|
||||
// Kill the whole process group, not just the wrapper shell:
|
||||
// librespeed-cli and the sampler must die with it, or a "stopped"
|
||||
// answer would leave the measurement running on inherited fds.
|
||||
// busybox kill takes the negative pgid without `--`.
|
||||
if (is_running() && st?.pid) {
|
||||
const pg = pgid_of(int(st.pid));
|
||||
|
||||
if (pg > 0)
|
||||
system(`kill -TERM -${pg} 2>/dev/null`);
|
||||
else
|
||||
system(`kill -TERM ${int(st.pid)} 2>/dev/null`);
|
||||
}
|
||||
|
||||
return { stopped: true };
|
||||
}
|
||||
},
|
||||
|
||||
status: {
|
||||
call: function() {
|
||||
const st = read_json(STATE) ?? {};
|
||||
|
||||
if (is_running()) {
|
||||
const out = { running: true, phase: st.phase ?? '' };
|
||||
|
||||
if (st.pid)
|
||||
out.pid = int(st.pid);
|
||||
if (st.started)
|
||||
out.started = int(st.started);
|
||||
if (st.mbps != null)
|
||||
out.mbps = st.mbps + 0.0;
|
||||
if (st.progress != null)
|
||||
out.progress = int(st.progress);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const out = { running: false, last_error: st.last_error ?? '' };
|
||||
|
||||
if (st.last_finished)
|
||||
out.last_finished = int(st.last_finished);
|
||||
|
||||
return out;
|
||||
}
|
||||
},
|
||||
|
||||
result: {
|
||||
call: function() {
|
||||
return read_json(RESULT) ?? {};
|
||||
}
|
||||
},
|
||||
|
||||
// Contract: one response comes from exactly one source. A range that fits
|
||||
// the raw retention window returns raw measurements; an older range is
|
||||
// served from the daily archive at 1d resolution (completed days only, so
|
||||
// today is absent there). The two never mix in one response --
|
||||
// `resolution` names the source, and a consumer comparing two windows
|
||||
// must compare like with like.
|
||||
history: {
|
||||
args: { from: 0, to: 0, limit: 0 },
|
||||
call: function(request) {
|
||||
const from = int(request.args?.from ?? 0);
|
||||
const to = int(request.args?.to ?? 0);
|
||||
const limit = int(request.args?.limit ?? 0);
|
||||
|
||||
const uci = cursor();
|
||||
const raw_path = config_get(uci, 'history', 'path',
|
||||
`${STATE_DIR}/history.jsonl`);
|
||||
const archive_path = config_get(uci, 'history', 'archive_path', '');
|
||||
const raw_days = int(config_get(uci, 'history', 'retention', '30d')) || 30;
|
||||
uci.unload('librespeed');
|
||||
|
||||
// Ranges the raw window can answer come from raw; anything reaching
|
||||
// further back is served from the daily archive when one is kept.
|
||||
// The hour of slack keeps the boundary request -- "the last 30
|
||||
// days" against a 30-day window -- from flapping between sources
|
||||
// over clock skew.
|
||||
let resolution = 'raw';
|
||||
let path = raw_path;
|
||||
|
||||
if (archive_path != '' && stat(archive_path) &&
|
||||
(from == 0 || from < time() - raw_days * 86400 - 3600)) {
|
||||
resolution = '1d';
|
||||
path = archive_path;
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
const f = open(path, 'r');
|
||||
|
||||
if (f) {
|
||||
for (let line = f.read('line'); length(line); line = f.read('line')) {
|
||||
let e = null;
|
||||
|
||||
try {
|
||||
e = json(line);
|
||||
}
|
||||
catch (err) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (from > 0 && int(e?.epoch ?? 0) < from)
|
||||
continue;
|
||||
if (to > 0 && int(e?.epoch ?? 0) > to)
|
||||
continue;
|
||||
|
||||
push(entries, e);
|
||||
}
|
||||
|
||||
f.close();
|
||||
}
|
||||
|
||||
// Newest N, still oldest first.
|
||||
const kept = (limit > 0 && length(entries) > limit)
|
||||
? slice(entries, -limit) : entries;
|
||||
|
||||
return { resolution: resolution, entries: kept };
|
||||
}
|
||||
},
|
||||
|
||||
config: {
|
||||
call: function() {
|
||||
const uci = cursor();
|
||||
|
||||
// The drawn schedule lives in the crontab, not in UCI: for a daily
|
||||
// interval the time is picked at sync. Handing the line out lets
|
||||
// the frontend show when measurements will actually run.
|
||||
let cron = '';
|
||||
const cf = open('/etc/crontabs/root', 'r');
|
||||
|
||||
if (cf) {
|
||||
for (let line = cf.read('line'); length(line); line = cf.read('line'))
|
||||
if (index(line, '/usr/libexec/librespeed-run') >= 0)
|
||||
cron = trim(line);
|
||||
cf.close();
|
||||
}
|
||||
|
||||
const out = {
|
||||
interface: config_get(uci, 'main', 'interface', 'wan'),
|
||||
server: config_get(uci, 'main', 'server', 'auto'),
|
||||
scheme: config_get(uci, 'main', 'scheme', 'auto'),
|
||||
server_list: config_get(uci, 'main', 'server_list', ''),
|
||||
schedule: {
|
||||
enabled: config_get(uci, 'schedule', 'enabled', '0') == '1',
|
||||
interval: config_get(uci, 'schedule', 'interval', '1d'),
|
||||
days: config_get(uci, 'schedule', 'days', '*'),
|
||||
hours: config_get(uci, 'schedule', 'hours', ''),
|
||||
cron: cron,
|
||||
next_runs: cron != '' ? cron_next(cron, 3) : []
|
||||
},
|
||||
history: {
|
||||
enabled: config_get(uci, 'history', 'enabled', '1') != '0',
|
||||
path: config_get(uci, 'history', 'path',
|
||||
`${STATE_DIR}/history.jsonl`),
|
||||
retention: config_get(uci, 'history', 'retention', '30d')
|
||||
}
|
||||
};
|
||||
|
||||
uci.unload('librespeed');
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { librespeed: methods };
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# The package ships scripts and an rpcd plugin, no binary of its own, so the
|
||||
# generic --version probe cannot apply; check the installed pieces instead.
|
||||
|
||||
fail() { echo "FAIL: $1"; exit 1; }
|
||||
|
||||
[ -x /usr/libexec/librespeed-run ] || fail "librespeed-run not installed"
|
||||
[ -x /usr/libexec/librespeed-aggregate ] || fail "librespeed-aggregate not installed"
|
||||
[ -f /usr/share/rpcd/ucode/librespeed.uc ] || fail "rpcd plugin not installed"
|
||||
[ -f /etc/config/librespeed ] || fail "UCI config not installed"
|
||||
[ -x /etc/init.d/librespeed ] || fail "init script not installed"
|
||||
|
||||
sh -n /usr/libexec/librespeed-run || fail "librespeed-run does not parse"
|
||||
/usr/libexec/librespeed-run --version | grep librespeed-common \
|
||||
|| fail "librespeed-run --version"
|
||||
/usr/libexec/librespeed-aggregate --version | grep librespeed-common \
|
||||
|| fail "librespeed-aggregate --version"
|
||||
sh -n /etc/init.d/librespeed || fail "init script does not parse"
|
||||
|
||||
# The plugin file has no side effects at load time: it defines its methods
|
||||
# and returns them. Run as a file, the way rpcd loads it -- include() cannot
|
||||
# be used here, it rejects the module import statements the plugin needs.
|
||||
ucode /usr/share/rpcd/ucode/librespeed.uc >/dev/null \
|
||||
|| fail "rpcd plugin does not load"
|
||||
|
||||
echo "librespeed-common: installed files OK"
|
||||
@@ -0,0 +1,11 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=LibreSpeed internet speed measurements
|
||||
LUCI_DEPENDS:=+luci-base +librespeed-common
|
||||
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
PKG_MAINTAINER:=Josef Schlehofer <pepe.schlehofer@gmail.com>
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -0,0 +1,506 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
|
||||
/* Shared between the Test page's recent-history block and the full History
|
||||
* page, so the two can never draw the same data differently. */
|
||||
|
||||
/* Labels are translated here, where the extractor can see them; consumers
|
||||
* take them display-ready, the way RANGES and PHASES do. */
|
||||
const METRICS = [
|
||||
[ 'download_mbps', _('Download'), 'Mbps' ],
|
||||
[ 'upload_mbps', _('Upload'), 'Mbps' ],
|
||||
[ 'ping_ms', _('Ping'), 'ms' ],
|
||||
[ 'jitter_ms', _('Jitter'), 'ms' ]
|
||||
];
|
||||
|
||||
/* Series that share a unit share an axis; mixing Mbps and ms on one scale
|
||||
* would flatten the milliseconds into the floor. */
|
||||
const GROUPS = {
|
||||
speed: [ 'download_mbps', 'upload_mbps' ],
|
||||
latency: [ 'ping_ms', 'jitter_ms' ]
|
||||
};
|
||||
|
||||
/* Labels are translated here, where the extractor can see them: switcher()
|
||||
* takes them display-ready. The key is the seconds, never the label. */
|
||||
const RANGES = [
|
||||
[ _('24h'), 86400 ],
|
||||
[ _('7d'), 604800 ],
|
||||
[ _('30d'), 2592000 ],
|
||||
[ _('1y'), 31536000 ],
|
||||
[ _('All'), 0 ]
|
||||
];
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/"/g, '""');
|
||||
}
|
||||
|
||||
function download(name, mime, text) {
|
||||
const blob = new Blob([ text ], { type: mime }),
|
||||
url = window.URL.createObjectURL(blob),
|
||||
link = E('a', { 'style': 'display:none', 'href': url, 'download': name });
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
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);
|
||||
}
|
||||
|
||||
/* A metric keeps one color everywhere: the first of its group wears the
|
||||
* theme's primary color, the second the fixed companion shade. */
|
||||
function colorIndexOf(key) {
|
||||
for (const g in GROUPS) {
|
||||
const i = GROUPS[g].indexOf(key);
|
||||
if (i >= 0)
|
||||
return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* One scale shared by the renderer and the hover layer: both must agree on
|
||||
* where a sample sits, or the tooltip would point beside the line. Spans
|
||||
* every requested series, and the day's min/max spread when aggregated. */
|
||||
function chartScale(entries, metrics, resolution) {
|
||||
const W = 600, H = 240, L = 46, R = 10, T = 14, B = 24;
|
||||
|
||||
if (!entries || entries.length < 1)
|
||||
return null;
|
||||
|
||||
const vals = [];
|
||||
|
||||
entries.forEach(e => {
|
||||
metrics.forEach(k => {
|
||||
if (typeof e[k] == 'number')
|
||||
vals.push(e[k]);
|
||||
if (resolution == '1d') {
|
||||
if (typeof e[k + '_min'] == 'number')
|
||||
vals.push(e[k + '_min']);
|
||||
if (typeof e[k + '_max'] == 'number')
|
||||
vals.push(e[k + '_max']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!vals.length)
|
||||
return null;
|
||||
|
||||
let min = Math.min.apply(null, vals),
|
||||
max = Math.max.apply(null, vals);
|
||||
|
||||
/* Vertical padding: 10% of the spread, but never less than 2% of the
|
||||
* value itself -- a connection steady within tenths of a Mbps must not
|
||||
* be zoomed into dramatic noise. A flat zero line still needs a
|
||||
* nonzero span to be drawable. */
|
||||
const pad = Math.max((max - min) * 0.1, Math.abs(max) * 0.02) || 1;
|
||||
min -= pad; max += pad;
|
||||
|
||||
return {
|
||||
W: W, H: H, L: L, R: R, T: T, B: B,
|
||||
min: min, max: max,
|
||||
xs: i => entries.length > 1
|
||||
? L + i * (W - L - R) / (entries.length - 1)
|
||||
: (L + W - R) / 2,
|
||||
ys: v => T + (H - T - B) * (1 - (v - min) / (max - min))
|
||||
};
|
||||
}
|
||||
|
||||
/* Timestamps for humans: daily aggregates carry a plain date, everything else
|
||||
* becomes the locale's date and time; garbage shows as itself. */
|
||||
function chartStampFull(e, resolution) {
|
||||
if (!e || !e.timestamp)
|
||||
return '?';
|
||||
if (resolution == '1d' || e.timestamp.indexOf('T') < 0)
|
||||
return e.timestamp;
|
||||
const d = new Date(e.timestamp);
|
||||
return isNaN(d.getTime()) ? e.timestamp : d.toLocaleString();
|
||||
}
|
||||
|
||||
function chartStampShort(e, resolution) {
|
||||
if (!e || !e.timestamp)
|
||||
return '';
|
||||
if (resolution == '1d' || e.timestamp.indexOf('T') < 0)
|
||||
return e.timestamp;
|
||||
const d = new Date(e.timestamp);
|
||||
return isNaN(d.getTime()) ? e.timestamp : d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function fmtVal(v, unit) {
|
||||
return (typeof v == 'number')
|
||||
? (v.toFixed(unit == 'Mbps' ? 2 : 1) + ' ' + unit) : '–';
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
METRICS: METRICS,
|
||||
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. */
|
||||
cssLink() {
|
||||
return E('link', {
|
||||
'rel': 'stylesheet',
|
||||
'href': L.resource('librespeed/librespeed.css') + '?v=' + CSS_VERSION
|
||||
});
|
||||
},
|
||||
|
||||
/* 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) {
|
||||
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;
|
||||
|
||||
return {
|
||||
count: vals.length,
|
||||
avg: avg,
|
||||
min: Math.min.apply(null, vals),
|
||||
max: Math.max.apply(null, vals),
|
||||
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] ])));
|
||||
},
|
||||
|
||||
/* The one chart component: hand-built SVG, the way the realtime status
|
||||
* graphs do it, colored by the stylesheet so it wears the active theme.
|
||||
*
|
||||
* opts.series keys drawn as lines (one unit family per chart)
|
||||
* opts.resolution 'raw' or '1d'; aggregates draw their min-max band
|
||||
* opts.hover guide line + a full-measurement tooltip
|
||||
* opts.legend { all, onToggle }: checkbox legend that switches
|
||||
* series on and off -- a control, not a caption
|
||||
*
|
||||
* The x axis is always time: a time series must never be reordered, or
|
||||
* the story it tells dissolves. Sorting belongs to the table.
|
||||
* Returns false when there is nothing to draw. */
|
||||
renderChart(container, entries, opts) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const series = (opts.series || []).filter(k => metricOf(k));
|
||||
const sc = chartScale(entries, series, opts.resolution);
|
||||
|
||||
if (!sc) {
|
||||
if (opts.legend)
|
||||
this.renderLegend(container, entries, opts);
|
||||
return false;
|
||||
}
|
||||
|
||||
const W = sc.W, H = sc.H, L = sc.L, R = sc.R, T = sc.T, B = sc.B;
|
||||
|
||||
const grid = [ 0.25, 0.5, 0.75 ].map(f => {
|
||||
const y = T + (H - T - B) * f,
|
||||
v = sc.max - (sc.max - sc.min) * f;
|
||||
return '<line x1="%d" y1="%f" x2="%d" y2="%f" stroke="currentColor" stroke-opacity="0.15"/>'
|
||||
.format(L, y, W - R, y) +
|
||||
'<text x="%d" y="%f" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="end">%s</text>'
|
||||
.format(L - 4, y + 3, v.toFixed(1));
|
||||
}).join('');
|
||||
|
||||
let body = '';
|
||||
|
||||
series.forEach(k => {
|
||||
const ci = colorIndexOf(k);
|
||||
const valid = entries
|
||||
.map((e, i) => (typeof e[k] == 'number') ? [ sc.xs(i), sc.ys(e[k]), e[k] ] : null)
|
||||
.filter(p => p != null);
|
||||
|
||||
if (!valid.length)
|
||||
return;
|
||||
|
||||
const pts = valid.map(p => '%f,%f'.format(p[0], p[1])).join(' ');
|
||||
|
||||
/* Aggregated entries carry the day's spread beside the mean,
|
||||
* drawn as a band under the mean line. */
|
||||
if (opts.resolution == '1d') {
|
||||
const lo = entries.map((e, i) => (typeof e[k + '_min'] == 'number')
|
||||
? '%f,%f'.format(sc.xs(i), sc.ys(e[k + '_min'])) : null).filter(p => p != null);
|
||||
const hi = entries.map((e, i) => (typeof e[k + '_max'] == 'number')
|
||||
? '%f,%f'.format(sc.xs(i), sc.ys(e[k + '_max'])) : null).filter(p => p != null);
|
||||
|
||||
if (lo.length)
|
||||
body += '<polygon class="librespeed-band librespeed-fill-%d" points="%s %s"/>'
|
||||
.format(ci, lo.join(' '), hi.reverse().join(' '));
|
||||
}
|
||||
|
||||
/* The soft fill reads well under one line; under two it is mud. */
|
||||
if (series.length == 1 && valid.length > 1)
|
||||
body += '<polygon class="librespeed-area librespeed-fill-%d" points="%s %f,%f %f,%f"/>'
|
||||
.format(ci, pts, valid[valid.length - 1][0], H - B, valid[0][0], H - B);
|
||||
|
||||
/* The dashed average is the series' "what is normal" reference. */
|
||||
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 += (valid.length > 1)
|
||||
? '<polyline class="librespeed-line librespeed-stroke-%d" points="%s"/>'.format(ci, pts)
|
||||
: '<circle class="librespeed-dot librespeed-fill-%d" cx="%f" cy="%f" r="3"/>'
|
||||
.format(ci, valid[0][0], valid[0][1]);
|
||||
|
||||
/* Dots make a sparse series readable; with hundreds of points
|
||||
* they would just thicken the line, so they bow out. */
|
||||
if (valid.length > 1 && valid.length <= 60)
|
||||
body += valid.map(p =>
|
||||
'<circle class="librespeed-dot librespeed-fill-%d" cx="%f" cy="%f" r="2"/>'
|
||||
.format(ci, p[0], p[1])).join('');
|
||||
});
|
||||
|
||||
/* A date-only timestamp names a local calendar day; new Date() would
|
||||
* read it as UTC midnight and shift it in negative offsets. */
|
||||
const first = entries[0],
|
||||
last = entries[entries.length - 1],
|
||||
mid = entries[Math.floor((entries.length - 1) / 2)];
|
||||
const s1 = chartStampShort(first, opts.resolution),
|
||||
s2 = chartStampShort(mid, opts.resolution),
|
||||
s3 = chartStampShort(last, opts.resolution);
|
||||
const midLabel = (entries.length > 2 && s2 != s1 && s2 != s3)
|
||||
? '<text x="%f" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="middle">%h</text>'
|
||||
.format((L + W - R) / 2, H - 6, s2)
|
||||
: '';
|
||||
|
||||
const unit = metricOf(series[0]) ? metricOf(series[0])[2] : '';
|
||||
|
||||
const chart = E('div', {});
|
||||
chart.innerHTML =
|
||||
'<svg viewBox="0 0 %d %d" style="width:100%%; max-height:280px" xmlns="http://www.w3.org/2000/svg">'
|
||||
.format(W, H) +
|
||||
grid + body +
|
||||
'<text x="%d" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6">%h</text>'
|
||||
.format(L, H - 6, s1) +
|
||||
midLabel +
|
||||
'<text x="%d" y="%d" font-size="10" fill="currentColor" fill-opacity="0.6" text-anchor="end">%h (%s)</text>'
|
||||
.format(W - R, H - 6, s3, unit) +
|
||||
'</svg>';
|
||||
container.appendChild(chart);
|
||||
|
||||
if (opts.hover !== false)
|
||||
this.attachChartHover(chart, entries, series, opts.resolution, sc);
|
||||
|
||||
if (opts.legend)
|
||||
this.renderLegend(container, entries, opts);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/* The legend doubles as the series switch: each entry is a checkbox with
|
||||
* the series' color and its average over the visible range. */
|
||||
renderLegend(container, entries, opts) {
|
||||
const leg = E('div', { 'class': 'librespeed-legend' });
|
||||
|
||||
opts.legend.all.forEach(k => {
|
||||
const m = metricOf(k);
|
||||
const st = this.seriesStats(entries, k);
|
||||
const cb = E('input', { 'type': 'checkbox' });
|
||||
|
||||
cb.checked = opts.series.indexOf(k) >= 0;
|
||||
cb.addEventListener('change', () => opts.legend.onToggle(k, cb.checked));
|
||||
|
||||
leg.appendChild(E('label', { 'class': 'librespeed-legend-item' }, [
|
||||
cb,
|
||||
E('span', { 'class': 'librespeed-swatch librespeed-bg-%d'.format(colorIndexOf(k)) }),
|
||||
E('span', {}, [ m[1] ]),
|
||||
st ? E('span', { 'class': 'librespeed-muted' },
|
||||
[ ' ' + fmtVal(st.avg, m[2]) + ' ' + _('avg') ]) : ''
|
||||
]));
|
||||
});
|
||||
|
||||
container.appendChild(leg);
|
||||
},
|
||||
|
||||
/* Hover layer: a guide line, a highlighted sample per series, and an HTML
|
||||
* tooltip carrying the whole measurement -- every metric, the server and
|
||||
* how the test travelled -- because whoever inspects one point wants to
|
||||
* know about that test, not one number of it. Daily aggregates show the
|
||||
* day's spread of the drawn series instead. */
|
||||
attachChartHover(container, entries, series, resolution, sc) {
|
||||
const svg = container.querySelector('svg');
|
||||
|
||||
if (!svg || !sc)
|
||||
return;
|
||||
|
||||
container.classList.add('librespeed-chart-holder');
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const guide = document.createElementNS(NS, 'line');
|
||||
guide.setAttribute('class', 'librespeed-guide');
|
||||
guide.setAttribute('y1', sc.T);
|
||||
guide.setAttribute('y2', sc.H - sc.B);
|
||||
guide.style.display = 'none';
|
||||
|
||||
const dots = series.map(k => {
|
||||
const d = document.createElementNS(NS, 'circle');
|
||||
d.setAttribute('class', 'librespeed-dot librespeed-fill-%d'.format(colorIndexOf(k)));
|
||||
d.setAttribute('r', '3.5');
|
||||
d.style.display = 'none';
|
||||
return d;
|
||||
});
|
||||
|
||||
const hit = document.createElementNS(NS, 'rect');
|
||||
hit.setAttribute('x', sc.L);
|
||||
hit.setAttribute('y', sc.T);
|
||||
hit.setAttribute('width', sc.W - sc.L - sc.R);
|
||||
hit.setAttribute('height', sc.H - sc.T - sc.B);
|
||||
hit.setAttribute('fill', 'transparent');
|
||||
|
||||
svg.appendChild(guide);
|
||||
dots.forEach(d => svg.appendChild(d));
|
||||
svg.appendChild(hit);
|
||||
|
||||
const tip = E('div', { 'class': 'librespeed-tooltip' });
|
||||
container.appendChild(tip);
|
||||
|
||||
const row = (lab, txt) => E('div', { 'class': 'librespeed-tooltip-row' }, [
|
||||
E('span', { 'class': 'librespeed-muted' }, [ lab ]),
|
||||
E('span', {}, [ txt ])
|
||||
]);
|
||||
|
||||
const hide = () => {
|
||||
guide.style.display = 'none';
|
||||
dots.forEach(d => (d.style.display = 'none'));
|
||||
tip.style.display = 'none';
|
||||
};
|
||||
|
||||
hit.addEventListener('mousemove', ev => {
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const x = (ev.clientX - rect.left) * sc.W / rect.width;
|
||||
const step = entries.length > 1
|
||||
? (sc.W - sc.L - sc.R) / (entries.length - 1) : 1;
|
||||
const i = Math.max(0, Math.min(entries.length - 1,
|
||||
Math.round((x - sc.L) / step)));
|
||||
const e = entries[i];
|
||||
|
||||
const px = sc.xs(i);
|
||||
let anchorY = null;
|
||||
|
||||
guide.setAttribute('x1', px);
|
||||
guide.setAttribute('x2', px);
|
||||
guide.style.display = '';
|
||||
|
||||
series.forEach((k, si) => {
|
||||
if (typeof e[k] == 'number') {
|
||||
const py = sc.ys(e[k]);
|
||||
dots[si].setAttribute('cx', px);
|
||||
dots[si].setAttribute('cy', py);
|
||||
dots[si].style.display = '';
|
||||
anchorY = (anchorY == null) ? py : Math.min(anchorY, py);
|
||||
}
|
||||
else
|
||||
dots[si].style.display = 'none';
|
||||
});
|
||||
|
||||
if (anchorY == null)
|
||||
return hide();
|
||||
|
||||
tip.innerHTML = '';
|
||||
tip.appendChild(E('div', { 'style': 'font-weight:600; margin-bottom:.2em' },
|
||||
[ chartStampFull(e, resolution) ]));
|
||||
|
||||
if (resolution == '1d') {
|
||||
series.forEach(k => {
|
||||
const m = metricOf(k);
|
||||
if (typeof e[k] != 'number')
|
||||
return;
|
||||
let txt = fmtVal(e[k], m[2]);
|
||||
if (typeof e[k + '_min'] == 'number')
|
||||
txt += ' (%s – %s)'.format(
|
||||
e[k + '_min'].toFixed(m[2] == 'Mbps' ? 0 : 1),
|
||||
e[k + '_max'].toFixed(m[2] == 'Mbps' ? 0 : 1));
|
||||
tip.appendChild(row(m[1], txt));
|
||||
});
|
||||
}
|
||||
else {
|
||||
METRICS.forEach(m => {
|
||||
if (typeof e[m[0]] == 'number')
|
||||
tip.appendChild(row(m[1], fmtVal(e[m[0]], m[2])));
|
||||
});
|
||||
|
||||
if (e.server && e.server.name)
|
||||
tip.appendChild(E('div', { 'style': 'margin-top:.2em' },
|
||||
[ e.server.name ]));
|
||||
|
||||
const meta = [];
|
||||
if (e.interface)
|
||||
meta.push(e.interface);
|
||||
if (e.family)
|
||||
meta.push(e.family == 'ipv6' ? 'IPv6' : 'IPv4');
|
||||
if (e.proto)
|
||||
meta.push(e.proto.toUpperCase());
|
||||
if (meta.length)
|
||||
tip.appendChild(E('div', { 'class': 'librespeed-muted' },
|
||||
[ meta.join(' · ') ]));
|
||||
}
|
||||
|
||||
tip.style.display = 'block';
|
||||
|
||||
const cr = container.getBoundingClientRect();
|
||||
const pxs = rect.left - cr.left + px * rect.width / sc.W;
|
||||
const pys = rect.top - cr.top + anchorY * rect.height / sc.H;
|
||||
let left = pxs - tip.offsetWidth / 2;
|
||||
left = Math.max(0, Math.min(cr.width - tip.offsetWidth, left));
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.top = Math.max(0, pys - tip.offsetHeight - 14) + 'px';
|
||||
});
|
||||
hit.addEventListener('mouseleave', hide);
|
||||
},
|
||||
|
||||
/* Daily aggregates carry each metric's spread beside the mean; the CSV
|
||||
* must not lose it, so aggregated exports gain the _min/_max columns. */
|
||||
exportCSV(entries, resolution) {
|
||||
const agg = (resolution == '1d');
|
||||
const keys = METRICS.map(m => m[0]);
|
||||
const n = v => (typeof v == 'number') ? v : '';
|
||||
|
||||
let head = 'timestamp,resolution,interface,family,proto,server,' + keys.join(',');
|
||||
|
||||
if (agg)
|
||||
head += ',' + keys.map(k => k + '_min,' + k + '_max').join(',');
|
||||
|
||||
const lines = [ head ];
|
||||
|
||||
(entries || []).forEach(e => {
|
||||
/* Every text field is quoted, so esc()'s quote-doubling is correct
|
||||
* everywhere and embedded commas cannot shift columns. */
|
||||
let row = '"%s","%s","%s","%s","%s","%s",'.format(
|
||||
esc(e.timestamp), agg ? '1d' : 'raw', esc(e.interface),
|
||||
esc(e.family), esc(e.proto), esc(e.server && e.server.name)) +
|
||||
keys.map(k => n(e[k])).join(',');
|
||||
|
||||
if (agg)
|
||||
row += ',' + keys.map(k => n(e[k + '_min']) + ',' + n(e[k + '_max'])).join(',');
|
||||
|
||||
lines.push(row);
|
||||
});
|
||||
|
||||
download('librespeed-history.csv', 'text/csv', lines.join('\n') + '\n');
|
||||
},
|
||||
|
||||
exportJSON(entries, resolution) {
|
||||
download('librespeed-history.json', 'application/json',
|
||||
JSON.stringify({ resolution: resolution || 'raw', entries: entries || [] }, null, '\t') + '\n');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/* The one piece of CSS this application owns: layout and type scale. Colors,
|
||||
* borders, buttons and spacing inside sections stay with the theme. */
|
||||
|
||||
.librespeed-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(18rem, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.librespeed-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.librespeed-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.librespeed-value {
|
||||
font-size: 2.2em;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.librespeed-value-sm {
|
||||
font-size: 1.5em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Muted through opacity rather than a hardcoded grey, so it dims whatever
|
||||
* foreground color the active theme uses. */
|
||||
.librespeed-muted {
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
.librespeed-caps {
|
||||
font-size: .85em;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.librespeed-center {
|
||||
text-align: center;
|
||||
padding: 1.5em 1em;
|
||||
}
|
||||
|
||||
.librespeed-progress {
|
||||
width: 60%;
|
||||
max-width: 20em;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
accent-color: var(--primary-color-medium, #5bc0de);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.librespeed-chart-fill {
|
||||
fill: var(--primary-color-medium, #5bc0de);
|
||||
fill-opacity: .1;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.librespeed-chart-band {
|
||||
fill: var(--primary-color-medium, #5bc0de);
|
||||
fill-opacity: .18;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.librespeed-chart-dot {
|
||||
fill: var(--primary-color-medium, #5bc0de);
|
||||
}
|
||||
|
||||
.librespeed-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .5em;
|
||||
margin: .4em 0;
|
||||
}
|
||||
|
||||
.librespeed-push {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.librespeed-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .5em;
|
||||
margin-top: .75em;
|
||||
}
|
||||
|
||||
.librespeed-steps {
|
||||
display: flex;
|
||||
gap: 1.75em;
|
||||
justify-content: center;
|
||||
margin: .5em 0 0;
|
||||
}
|
||||
|
||||
.librespeed-step { opacity: .35; }
|
||||
.librespeed-step-active { opacity: 1; font-weight: 600; }
|
||||
.librespeed-step-done { opacity: .65; }
|
||||
|
||||
.librespeed-spark {
|
||||
width: 100%;
|
||||
height: 72px;
|
||||
margin: .75em 0 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.librespeed-gauge {
|
||||
width: 250px;
|
||||
max-width: 100%;
|
||||
margin: .5em auto 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.librespeed-gauge-bg {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-opacity: .12;
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.librespeed-gauge-fill {
|
||||
fill: none;
|
||||
stroke: var(--primary-color-medium, #5bc0de);
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.librespeed-gauge text { fill: currentColor; }
|
||||
|
||||
.librespeed-gauge-tick {
|
||||
stroke: currentColor;
|
||||
stroke-opacity: .25;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.librespeed-gauge-needle {
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.librespeed-gauge-hub { fill: currentColor; }
|
||||
|
||||
.librespeed-chart-holder { position: relative; }
|
||||
|
||||
.librespeed-guide {
|
||||
stroke: currentColor;
|
||||
stroke-opacity: .3;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.librespeed-avg {
|
||||
stroke: currentColor;
|
||||
stroke-opacity: .45;
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.librespeed-tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
background: var(--background-color-high, #fff);
|
||||
color: var(--text-color-high, #333);
|
||||
border: 1px solid rgba(128, 128, 128, .4);
|
||||
border-radius: 3px;
|
||||
padding: .3em .6em;
|
||||
font-size: .85em;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.librespeed-tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.25em;
|
||||
}
|
||||
|
||||
.librespeed-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1.5em;
|
||||
margin-top: .5em;
|
||||
}
|
||||
|
||||
.librespeed-line { fill: none; stroke-width: 1.5; }
|
||||
.librespeed-area { stroke: none; fill-opacity: .1; }
|
||||
.librespeed-band { stroke: none; fill-opacity: .16; }
|
||||
.librespeed-dot { stroke: none; }
|
||||
|
||||
.librespeed-avgline {
|
||||
fill: none;
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 3;
|
||||
stroke-opacity: .55;
|
||||
}
|
||||
|
||||
/* 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); }
|
||||
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); }
|
||||
circle.librespeed-fill-1 { fill-opacity: .55; }
|
||||
.librespeed-bg-0 { background: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-bg-1 {
|
||||
background: repeating-linear-gradient(90deg,
|
||||
var(--primary-color-medium, #5bc0de) 0 4px, transparent 4px 7px);
|
||||
}
|
||||
|
||||
.librespeed-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5em;
|
||||
margin: .35em 0 0;
|
||||
}
|
||||
|
||||
.librespeed-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.librespeed-swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.librespeed-result-gauges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1.5em;
|
||||
margin: .5em 0 0;
|
||||
}
|
||||
|
||||
.librespeed-result-gauges .librespeed-gauge {
|
||||
width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.librespeed-fg-0 { color: var(--primary-color-medium, #5bc0de); }
|
||||
.librespeed-fg-1 { color: var(--primary-color-medium, #5bc0de); }
|
||||
|
||||
.librespeed-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .75em;
|
||||
margin: .35em 0 1em;
|
||||
}
|
||||
|
||||
.librespeed-metric-card {
|
||||
border: 1px solid rgba(128, 128, 128, .25);
|
||||
border-top-width: 3px;
|
||||
border-radius: 4px;
|
||||
padding: .45em .9em;
|
||||
min-width: 11em;
|
||||
cursor: pointer;
|
||||
opacity: .5;
|
||||
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-card-value {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.librespeed-hint {
|
||||
border-bottom: 1px dotted currentColor;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.librespeed-meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: .15em 1.25em;
|
||||
width: fit-content;
|
||||
margin: 1em auto 0;
|
||||
text-align: left;
|
||||
font-size: .95em;
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
'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 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()
|
||||
};
|
||||
return this.fetch();
|
||||
},
|
||||
|
||||
/* 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),
|
||||
ps = lscommon.seriesStats(prev, k),
|
||||
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')
|
||||
}, [
|
||||
E('div', { 'class': 'librespeed-muted 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) {
|
||||
this.emptyText.textContent = noneAtAll
|
||||
? _('Run a speed test to start building your connection history.')
|
||||
: _('No measurements match the current filters.');
|
||||
this.emptyStart.style.display = noneAtAll ? '' : 'none';
|
||||
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.') ]));
|
||||
|
||||
const num = v => (typeof v == 'number') ? v : -1;
|
||||
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();
|
||||
};
|
||||
|
||||
/* 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(' · ') || '–';
|
||||
};
|
||||
|
||||
const rows = entries.slice().reverse();
|
||||
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).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) ]
|
||||
]));
|
||||
},
|
||||
|
||||
/* 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' },
|
||||
[ _('%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();
|
||||
|
||||
const renderControls = L.bind(function() {
|
||||
const groups = lscommon.switcher(this,
|
||||
[ [ 'speed', _('Speed') ], [ 'latency', _('Latency') ] ], this.group,
|
||||
function(v) { this.group = v; renderControls(); this.redraw(); });
|
||||
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));
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require form';
|
||||
'require rpc';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
const callConfig = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'config',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
return view.extend({
|
||||
load() {
|
||||
return callConfig().catch(() => ({}));
|
||||
},
|
||||
|
||||
render(config) {
|
||||
let m, s, o;
|
||||
|
||||
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.default = 'wan';
|
||||
o.nocreate = true;
|
||||
|
||||
o = s.option(form.Value, 'server', _('Server'),
|
||||
_("Numeric server id from the LibreSpeed server list, or 'auto' to pick the closest."));
|
||||
o.default = 'auto';
|
||||
o.validate = function(section_id, value) {
|
||||
if (value == '' || value == 'auto' || /^[0-9]+$/.test(value))
|
||||
return true;
|
||||
return _("Must be a server id or 'auto'");
|
||||
};
|
||||
|
||||
o = s.option(form.Value, 'server_list', _('Server list URL'),
|
||||
_('URL of a LibreSpeed server list in JSON, from a self-hosted deployment: either a static file such as https://example.org/servers.json or a generator such as https://example.org/backend/servers.php. Leave empty for the official list.'));
|
||||
o.optional = true;
|
||||
o.placeholder = 'https://librespeed.org/backend-servers/servers.php';
|
||||
o.validate = function(section_id, value) {
|
||||
if (value == '' || /^https?:\/\/.+/.test(value))
|
||||
return true;
|
||||
return _('Must be an http(s) URL');
|
||||
};
|
||||
|
||||
o = s.option(form.ListValue, 'scheme', _('Protocol'),
|
||||
_('On routers without AES acceleration, TLS itself can bound the result — forcing plain HTTP then measures the line rather than the cipher.'));
|
||||
o.value('auto', _('server default'));
|
||||
o.value('https', _('force HTTPS'));
|
||||
o.value('http', _('force HTTP'));
|
||||
o.default = 'auto';
|
||||
|
||||
s = m.section(form.NamedSection, 'schedule', 'librespeed', _('Scheduled measurements'));
|
||||
|
||||
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.'));
|
||||
|
||||
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'));
|
||||
o.default = '1d';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
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.optional = true;
|
||||
o.depends('enabled', '1');
|
||||
/* 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(',');
|
||||
};
|
||||
o.write = function(section_id, value) {
|
||||
const list = Array.isArray(value) ? value.filter(v => v !== '') : [];
|
||||
if (!list.length)
|
||||
return this.remove(section_id);
|
||||
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.placeholder = '2-5';
|
||||
o.optional = true;
|
||||
o.depends('enabled', '1');
|
||||
o.validate = function(section_id, value) {
|
||||
if (value == '')
|
||||
return true;
|
||||
const m = value.match(/^([01]?[0-9]|2[0-3])(-([01]?[0-9]|2[0-3]))?$/);
|
||||
if (!m)
|
||||
return _('Use an hour (0-23) or a range like 2-5');
|
||||
if (m[3] != null && +m[3] < +m[1])
|
||||
return _('The window must not cross midnight');
|
||||
return true;
|
||||
};
|
||||
|
||||
s = m.section(form.NamedSection, 'history', 'librespeed', _('History'));
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Keep history'));
|
||||
o.default = '1';
|
||||
|
||||
o = s.option(form.Value, 'path', _('Path'),
|
||||
_('History stored in /tmp is lost when the router reboots.'));
|
||||
o.default = '/tmp/librespeed/history.jsonl';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.ListValue, 'retention', _('Retention'));
|
||||
o.value('7d', _('7 days'));
|
||||
o.value('30d', _('30 days'));
|
||||
o.value('90d', _('90 days'));
|
||||
o.value('365d', _('1 year'));
|
||||
o.default = '30d';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.Value, 'archive_path', _('Archive path'),
|
||||
_('Completed days are reduced to daily minimum, average and maximum and appended here once a day, so measurements since the last flush may be lost after an unexpected power loss. Leave empty to keep no persistent history.'));
|
||||
o.placeholder = '/etc/librespeed/history-daily.jsonl';
|
||||
o.optional = true;
|
||||
o.depends('enabled', '1');
|
||||
|
||||
o = s.option(form.ListValue, 'archive_retention', _('Archive retention'));
|
||||
o.value('90d', _('90 days'));
|
||||
o.value('365d', _('1 year'));
|
||||
o.value('730d', _('2 years'));
|
||||
o.default = '365d';
|
||||
o.depends('enabled', '1');
|
||||
|
||||
return m.render().then(node => {
|
||||
const cron = config && config.schedule && config.schedule.cron;
|
||||
const box = E('div', { 'style': 'margin-top:1em' });
|
||||
|
||||
if (cron) {
|
||||
/* 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('p', { 'style': 'color:#888' }, [
|
||||
_('A daily measurement runs at a time drawn when the schedule is applied; saving draws it anew.')
|
||||
]));
|
||||
}
|
||||
else {
|
||||
box.appendChild(E('p', { 'style': 'color:#888' }, [
|
||||
_('No schedule is active. Enable it above and apply to draw the measurement time.')
|
||||
]));
|
||||
}
|
||||
|
||||
node.appendChild(box);
|
||||
return node;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,701 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require ui';
|
||||
'require poll';
|
||||
'require rpc';
|
||||
'require librespeed.common as lscommon';
|
||||
|
||||
/* The alias comes from the require above; the repo eslint config only
|
||||
* knows the stock module names. */
|
||||
/* global lscommon */
|
||||
|
||||
const callStart = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'start',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callStop = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'stop',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callStatus = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'status',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callResult = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'result',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callConfig = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'config',
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
const callHistory = rpc.declare({
|
||||
object: 'librespeed',
|
||||
method: 'history',
|
||||
params: [ 'from', 'to', 'limit' ],
|
||||
expect: { '': {} }
|
||||
});
|
||||
|
||||
/* The Test page is an overview, not analysis: a fixed last-24h download
|
||||
* chart. No entry limit -- a 15-minute schedule makes 96 points a day, which
|
||||
* is nothing for the SVG, and a capped fetch would quietly turn "last 24
|
||||
* hours" into "last few hours". The History page fetches whatever range the
|
||||
* user asks for; this page never should. */
|
||||
const recentSeconds = 86400;
|
||||
const recentRows = 5;
|
||||
|
||||
/* A measurement takes tens of seconds, so nothing waits on it: start returns as
|
||||
* soon as the job is detached and this is how often we ask how it is going. */
|
||||
const pollInterval = 2;
|
||||
|
||||
function fmt(value, digits) {
|
||||
return (typeof value == 'number' && isFinite(value))
|
||||
? value.toFixed(digits != null ? digits : 2)
|
||||
: '–';
|
||||
}
|
||||
|
||||
function fmtTime(epochOrIso) {
|
||||
if (!epochOrIso)
|
||||
return '';
|
||||
|
||||
const d = (typeof epochOrIso == 'number')
|
||||
? new Date(epochOrIso * 1000)
|
||||
: new Date(epochOrIso);
|
||||
|
||||
return isNaN(d.getTime()) ? String(epochOrIso) : d.toLocaleString();
|
||||
}
|
||||
|
||||
function fmtElapsed(seconds) {
|
||||
if (!(seconds >= 0))
|
||||
return '';
|
||||
|
||||
const m = Math.floor(seconds / 60),
|
||||
s = Math.floor(seconds % 60);
|
||||
|
||||
return '%d:%02d'.format(m, s);
|
||||
}
|
||||
|
||||
/* The three stages a measurement walks through, with the share of the overall
|
||||
* bar each one gets. Ping streams no percent, so it holds a token slice. */
|
||||
const PHASES = [
|
||||
[ 'ping', 0, 10, _('Ping') ],
|
||||
[ 'download', 10, 55, _('Download') ],
|
||||
[ 'upload', 55, 100, _('Upload') ]
|
||||
];
|
||||
|
||||
/* Maps the streamed per-phase percent onto one overall run percent, so the
|
||||
* bar moves forward through the whole run instead of refilling per phase.
|
||||
* Null means there is nothing honest to show and the bar stays indeterminate. */
|
||||
function overallProgress(status) {
|
||||
const span = PHASES.find(p => p[0] == status.phase);
|
||||
|
||||
if (!span)
|
||||
return null;
|
||||
if (typeof status.progress != 'number')
|
||||
return (status.phase == 'ping') ? 5 : null;
|
||||
|
||||
return Math.round(span[1] + (span[2] - span[1]) * Math.min(status.progress, 100) / 100);
|
||||
}
|
||||
|
||||
function polar(cx, cy, r, deg) {
|
||||
const a = deg * Math.PI / 180;
|
||||
return [ cx + r * Math.cos(a), cy - r * Math.sin(a) ];
|
||||
}
|
||||
|
||||
/* The dial's top end: the smallest "nice" ceiling above the fastest rate seen,
|
||||
* so a 550 Mbps line gets a 600 dial rather than pinning the needle at an
|
||||
* arbitrary full stop. Grows during the run when the rate outruns it. */
|
||||
function niceTop(mbps) {
|
||||
/* Each of these divides cleanly by 5 and 10, so the dial's labels and
|
||||
* ticks always land on round numbers: a ~560 Mbps line gets a 0-1000
|
||||
* dial labelled every 200 with ticks every 100. */
|
||||
const steps = [ 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000 ];
|
||||
|
||||
for (let i = 0; i < steps.length; i++)
|
||||
if (mbps <= steps[i])
|
||||
return steps[i];
|
||||
|
||||
return Math.ceil(mbps / 10000) * 10000;
|
||||
}
|
||||
|
||||
function gaugeLabel(v) {
|
||||
return v >= 1000 ? (v / 1000) + ' G' : String(v);
|
||||
}
|
||||
|
||||
/* The running test's centrepiece: a circular speedometer with a needle. The
|
||||
* dial is the current rate against the niceTop() range -- never the test's
|
||||
* completion, which is a different number and reads as a small percentage
|
||||
* below the circle. */
|
||||
function gauge(mbps, top, ci) {
|
||||
ci = ci || 0;
|
||||
const cx = 100, cy = 95, r = 80, span = 216, start = 198;
|
||||
const p1 = polar(cx, cy, r, start),
|
||||
p2 = polar(cx, cy, r, start - span);
|
||||
const len = span / 360 * 2 * Math.PI * r;
|
||||
const path = 'M %f %f A %f %f 0 1 1 %f %f'.format(p1[0], p1[1], r, r, p2[0], p2[1]);
|
||||
const amt = (typeof mbps == 'number')
|
||||
? Math.max(0, Math.min(1, mbps / top)) : 0;
|
||||
|
||||
/* Ticks every tenth of the range, longer ones with a label every fifth:
|
||||
* a 0-1000 dial reads 0/200/400/600/800/1000 with marks each 100. */
|
||||
let marks = '';
|
||||
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const a = start - i / 10 * span,
|
||||
major = (i % 2 == 0),
|
||||
t1 = polar(cx, cy, r - 10, a),
|
||||
t2 = polar(cx, cy, r - (major ? 18 : 14), a);
|
||||
marks += '<line class="librespeed-gauge-tick" x1="%f" y1="%f" x2="%f" y2="%f"/>'
|
||||
.format(t1[0], t1[1], t2[0], t2[1]);
|
||||
|
||||
if (major) {
|
||||
const p = polar(cx, cy, r - 28, a);
|
||||
marks += '<text x="%f" y="%f" text-anchor="middle" font-size="8.5" fill-opacity=".55">%s</text>'
|
||||
.format(p[0], p[1] + 3, gaugeLabel(top * i / 10));
|
||||
}
|
||||
}
|
||||
|
||||
const tip = polar(cx, cy, r - 20, start - amt * span);
|
||||
|
||||
return '<svg class="librespeed-gauge" viewBox="0 0 200 150" xmlns="http://www.w3.org/2000/svg">' +
|
||||
'<path class="librespeed-gauge-bg" d="%s"/>'.format(path) +
|
||||
'<path class="librespeed-gauge-fill librespeed-stroke-%d" d="%s" stroke-dasharray="%f %f"/>'
|
||||
.format(ci, path, amt * len, len) +
|
||||
marks +
|
||||
'<line class="librespeed-gauge-needle" x1="%f" y1="%f" x2="%f" y2="%f"/>'
|
||||
.format(cx, cy, tip[0], tip[1]) +
|
||||
'<circle class="librespeed-gauge-hub" cx="%f" cy="%f" r="4"/>'.format(cx, cy) +
|
||||
'<text x="100" y="133" text-anchor="middle" font-size="26" font-weight="500">%s</text>'
|
||||
.format((typeof mbps == 'number') ? '%.2f'.format(mbps) : '–') +
|
||||
'<text x="100" y="147" text-anchor="middle" font-size="11" fill-opacity=".6">Mbps</text>' +
|
||||
'</svg>';
|
||||
}
|
||||
|
||||
/* One value with its small label underneath; the figures are the point of the
|
||||
* page, so they get the size. */
|
||||
function figure(value, unit, label, big) {
|
||||
return E('div', {}, [
|
||||
E('div', { 'class': big ? 'librespeed-value' : 'librespeed-value-sm' }, [
|
||||
value,
|
||||
E('span', { 'class': 'librespeed-muted', 'style': 'font-size:.45em' }, [ ' ' + unit ])
|
||||
]),
|
||||
E('div', { 'class': 'librespeed-muted librespeed-caps' }, [ label ])
|
||||
]);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
alertNode: null,
|
||||
statusNode: null,
|
||||
resultHeading: null,
|
||||
resultNode: null,
|
||||
button: null,
|
||||
lastSeen: null,
|
||||
config: {},
|
||||
|
||||
load() {
|
||||
return Promise.all([
|
||||
callStatus().catch(() => ({})),
|
||||
callResult().catch(() => ({})),
|
||||
callConfig().catch(() => ({})),
|
||||
callHistory(Math.floor(Date.now() / 1000 - recentSeconds),
|
||||
undefined, 0).catch(() => ({}))
|
||||
]);
|
||||
},
|
||||
|
||||
renderRecent(data) {
|
||||
const entries = Array.isArray(data && data.entries) ? data.entries : [];
|
||||
|
||||
/* Kept so switching the metric can redraw without refetching. */
|
||||
this.recentData = data;
|
||||
|
||||
this.recentNode.innerHTML = '';
|
||||
this.recentCount.textContent = entries.length
|
||||
? _('%d measurements').format(entries.length) : '';
|
||||
|
||||
/* The active button names the metric, so the subtitle only carries
|
||||
* the window. */
|
||||
this.recentSwitch.replaceChildren(...lscommon.switcher(this,
|
||||
lscommon.GROUPS.speed.map(k => {
|
||||
const m = lscommon.METRICS.find(x => x[0] == k);
|
||||
return [ k, m[1] ];
|
||||
}), this.recentMetric,
|
||||
function(v) {
|
||||
this.recentMetric = v;
|
||||
this.renderRecent(this.recentData);
|
||||
}).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);
|
||||
|
||||
if (st && st.count > 1) {
|
||||
const varPct = (st.max - st.min) / st.avg * 100,
|
||||
offPct = (st.current - st.avg) / st.avg * 100;
|
||||
let story;
|
||||
|
||||
/* The heading above names the metric, so the sentence does not
|
||||
* repeat it -- and a word like "download" cannot be spliced into
|
||||
* a translated sentence anyway. */
|
||||
if (offPct < -15)
|
||||
story = _('Declining — the last measurement is %d%% below the 24-hour average.')
|
||||
.format(Math.round(-offPct));
|
||||
else if (varPct > 30)
|
||||
story = _('Variable — ranged from %d to %d Mbps.')
|
||||
.format(Math.round(st.min), Math.round(st.max));
|
||||
else
|
||||
story = _('Stable — averaged %d Mbps, ranging from %d to %d Mbps.')
|
||||
.format(Math.round(st.avg), Math.round(st.min), Math.round(st.max));
|
||||
|
||||
this.recentNode.appendChild(E('p', { 'class': 'librespeed-muted', 'style': 'margin:.25em 0 .5em' },
|
||||
[ story ]));
|
||||
}
|
||||
|
||||
const chart = E('div', {});
|
||||
this.recentNode.appendChild(chart);
|
||||
|
||||
const drew = lscommon.renderChart(chart, entries, {
|
||||
series: [ metric ],
|
||||
resolution: data && data.resolution,
|
||||
hover: true
|
||||
});
|
||||
|
||||
/* Entries may well exist -- the count above says so -- while none of
|
||||
* them carries a download figure, so name the window rather than
|
||||
* claiming there is nothing at all. */
|
||||
if (!drew) {
|
||||
this.recentNode.appendChild(E('p', { 'class': 'librespeed-muted' },
|
||||
[ _('No data in the last 24 hours.') ]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (st && st.count > 1) {
|
||||
const stat = (label, v) => E('span', {}, [
|
||||
E('strong', {}, [ '%d'.format(Math.round(v)) ]),
|
||||
E('span', { 'class': 'librespeed-muted' }, [ ' ' + label ])
|
||||
]);
|
||||
|
||||
this.recentNode.appendChild(E('div', {
|
||||
'class': 'librespeed-muted librespeed-stats',
|
||||
'style': 'margin:1em 0 1.5em'
|
||||
}, [
|
||||
stat(_('current'), st.current),
|
||||
stat(_('average'), st.avg),
|
||||
stat(_('best'), st.max),
|
||||
stat(_('worst'), st.min)
|
||||
]));
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
const num = v => (typeof v == 'number') ? v : -1;
|
||||
|
||||
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) ]
|
||||
]));
|
||||
this.recentNode.appendChild(this.recentTableNode);
|
||||
|
||||
this.recentNode.appendChild(E('div', { 'style': 'text-align:right; margin-top:.75em' }, [
|
||||
E('a', { 'href': L.url('admin', 'network', 'librespeed', 'history') },
|
||||
[ _('View full history »') ])
|
||||
]));
|
||||
},
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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));
|
||||
},
|
||||
|
||||
handleStop(ev) {
|
||||
return callStop().then(L.bind(function() {
|
||||
this.setBusy(false);
|
||||
}, this));
|
||||
},
|
||||
|
||||
setBusy(running) {
|
||||
if (!this.button)
|
||||
return;
|
||||
|
||||
this.button.textContent = running ? _('Stop test') : _('Start test');
|
||||
this.button.className = running ? 'cbi-button cbi-button-reset'
|
||||
: 'cbi-button cbi-button-action';
|
||||
this.button.onclick = ui.createHandlerFn(this,
|
||||
running ? 'handleStop' : 'handleStart');
|
||||
},
|
||||
|
||||
runContext() {
|
||||
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);
|
||||
|
||||
return parts.join(' · ');
|
||||
},
|
||||
|
||||
renderStatus(status) {
|
||||
this.alertNode.innerHTML = '';
|
||||
this.statusNode.innerHTML = '';
|
||||
|
||||
/* The result block steps aside while a measurement runs -- the page
|
||||
* narrates one thing at a time. */
|
||||
this.resultNode.style.display = status.running ? 'none' : '';
|
||||
this.resultHeading.textContent = (!status.running && status.last_error)
|
||||
? _('Last successful measurement') : '';
|
||||
|
||||
if (status.running) {
|
||||
const rows = [];
|
||||
|
||||
/* The walk through the stages, with the current one highlighted --
|
||||
* this is what says why nothing moves yet while connecting. */
|
||||
const phaseIdx = PHASES.findIndex(p => p[0] == status.phase);
|
||||
|
||||
rows.push(E('div', { 'class': 'librespeed-steps librespeed-caps' },
|
||||
PHASES.map((p, i) => E('span', {
|
||||
'class': 'librespeed-step' +
|
||||
(i == phaseIdx ? ' librespeed-step-active' :
|
||||
(phaseIdx >= 0 && i < phaseIdx ? ' librespeed-step-done' : ''))
|
||||
}, [ p[3] ]))));
|
||||
|
||||
/* The dial range is frozen at the start of the run -- a familiar
|
||||
* line gets a familiar dial -- and only ever expands, once the
|
||||
* rate actually exceeds it. Recomputing it downwards mid-run
|
||||
* would pull the needle back while the speed grows. */
|
||||
if (!this.wasRunning)
|
||||
this.runTop = niceTop(Math.max(this.lastPeak || 0, 50) * 1.02);
|
||||
if (typeof status.mbps == 'number' && status.mbps > this.runTop)
|
||||
this.runTop = niceTop(status.mbps * 1.02);
|
||||
this.wasRunning = true;
|
||||
|
||||
/* The spinner is LuCI's own; while the client still negotiates
|
||||
* with the server there is nothing to point the gauge at. */
|
||||
if (phaseIdx < 0)
|
||||
rows.push(E('p', { 'class': 'spinning', 'style': 'margin-top:.75em' },
|
||||
[ _('Connecting to the test server…') ]));
|
||||
else {
|
||||
const holder = E('div', {});
|
||||
holder.innerHTML = gauge(
|
||||
(typeof status.mbps == 'number' && status.mbps > 0)
|
||||
? status.mbps : null,
|
||||
this.runTop,
|
||||
status.phase == 'upload' ? 1 : 0);
|
||||
rows.push(holder);
|
||||
}
|
||||
|
||||
/* Completion is a different number than the rate on the arc, so
|
||||
* it stays a small figure below the circle rather than feeding
|
||||
* the gauge and pretending to be a speed. */
|
||||
const overall = overallProgress(status);
|
||||
|
||||
if (overall != null)
|
||||
rows.push(E('div', { 'class': 'librespeed-muted' },
|
||||
[ overall + ' %' ]));
|
||||
|
||||
const elapsed = status.started
|
||||
? fmtElapsed(Date.now() / 1000 - status.started) : '';
|
||||
|
||||
if (elapsed)
|
||||
rows.push(E('div', { 'class': 'librespeed-muted' },
|
||||
[ _('%s elapsed').format(elapsed) ]));
|
||||
|
||||
const ctx = this.runContext();
|
||||
|
||||
if (ctx)
|
||||
rows.push(E('div', { 'class': 'librespeed-muted', 'style': 'margin-top:.5em' }, [ ctx ]));
|
||||
|
||||
rows.forEach(r => this.statusNode.appendChild(r));
|
||||
}
|
||||
else
|
||||
this.wasRunning = false;
|
||||
|
||||
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', {
|
||||
'class': 'alert-message warning',
|
||||
'style': 'display:inline-block; text-align:left'
|
||||
}, [
|
||||
E('strong', {}, [ _('Last measurement failed') ]),
|
||||
E('br'),
|
||||
status.last_error,
|
||||
status.last_finished ? ' · ' + fmtTime(status.last_finished) : ''
|
||||
]));
|
||||
}
|
||||
},
|
||||
|
||||
renderResult(result) {
|
||||
this.resultNode.innerHTML = '';
|
||||
|
||||
/* Remembered as the dial-range seed for the next run. */
|
||||
if (result && (typeof result.download_mbps == 'number' || typeof result.upload_mbps == 'number'))
|
||||
this.lastPeak = Math.max(result.download_mbps || 0, result.upload_mbps || 0);
|
||||
|
||||
if (!result || result.download_mbps == null) {
|
||||
this.resultNode.appendChild(E('p', { 'class': 'librespeed-muted', 'style': 'margin-top:.5em' }, [
|
||||
_('No measurement yet.')
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
/* The result in the same visual language the run used: one dial per
|
||||
* direction, both on one shared scale so they compare at a glance,
|
||||
* with the latency figures small beside them. */
|
||||
const top = niceTop(Math.max(result.download_mbps || 0,
|
||||
result.upload_mbps || 0, 50) * 1.02);
|
||||
/* Same color language as the History chart -- download is always the
|
||||
* theme color, upload the companion shade -- plus the direction arrow,
|
||||
* so the two dials cannot be mistaken for one another. */
|
||||
const dial = (v, label, ci, arrow) => {
|
||||
const holder = E('div', { 'class': 'librespeed-result-gauge' });
|
||||
holder.innerHTML = gauge((typeof v == 'number') ? v : null, top, ci);
|
||||
holder.appendChild(E('div', { 'class': 'librespeed-caps' }, [
|
||||
E('span', { 'class': 'librespeed-fg-%d'.format(ci), 'style': 'font-weight:700' },
|
||||
[ arrow + ' ' ]),
|
||||
E('span', { 'class': 'librespeed-muted' }, [ label ])
|
||||
]));
|
||||
return holder;
|
||||
};
|
||||
|
||||
this.resultNode.appendChild(E('div', { 'class': 'librespeed-result-gauges' }, [
|
||||
dial(result.download_mbps, _('Download'), 0, '\u2193'),
|
||||
dial(result.upload_mbps, _('Upload'), 1, '\u2191')
|
||||
]));
|
||||
|
||||
this.resultNode.appendChild(E('div', {
|
||||
'style': 'display:flex; justify-content:center; gap:3.5em; margin-top:.5em'
|
||||
}, [
|
||||
figure(fmt(result.ping_ms, 1), 'ms', _('Ping'), false),
|
||||
figure(fmt(result.jitter_ms, 1), 'ms', _('Jitter'), false)
|
||||
]));
|
||||
|
||||
/* One fact per row rather than one long sentence: each has its own
|
||||
* meaning and the eye can scan for the one it wants. */
|
||||
const meta = [];
|
||||
|
||||
if (result.server && result.server.name)
|
||||
meta.push([ _('Server'), result.server.name ]);
|
||||
if (result.interface)
|
||||
meta.push([ _('Interface'), result.interface ]);
|
||||
|
||||
const proto = [];
|
||||
|
||||
if (result.family)
|
||||
proto.push(result.family == 'ipv6' ? 'IPv6' : 'IPv4');
|
||||
if (result.proto)
|
||||
proto.push(result.proto == 'https' ? _('encrypted (HTTPS)') : _('plain HTTP'));
|
||||
if (proto.length)
|
||||
meta.push([ _('Protocol'), proto.join(' \u00b7 ') ]);
|
||||
|
||||
if (result.timestamp)
|
||||
meta.push([ _('Completed'), fmtTime(result.timestamp) ]);
|
||||
|
||||
const grid = E('div', { 'class': 'librespeed-meta' });
|
||||
|
||||
meta.forEach(m => {
|
||||
grid.appendChild(E('span', { 'class': 'librespeed-muted' }, [ m[0] ]));
|
||||
grid.appendChild(E('span', {}, [ m[1] ]));
|
||||
});
|
||||
|
||||
this.resultNode.appendChild(grid);
|
||||
|
||||
/* The link comes from the telemetry server's reply -- from outside
|
||||
* the router -- so only plain web schemes may pass into the href. */
|
||||
if (result.share && /^https?:\/\//i.test(result.share))
|
||||
this.resultNode.appendChild(E('p', { 'style': 'margin:.5em 0 0' }, [
|
||||
E('a', {
|
||||
'href': result.share,
|
||||
'target': '_blank',
|
||||
'rel': 'noreferrer'
|
||||
}, [ _('View result') ])
|
||||
]));
|
||||
},
|
||||
|
||||
render(data) {
|
||||
const status = data[0] || {},
|
||||
result = data[1] || {},
|
||||
config = data[2] || {},
|
||||
recent = data[3] || {};
|
||||
|
||||
this.config = config;
|
||||
this.alertNode = E('div', {});
|
||||
this.statusNode = E('div', {});
|
||||
this.resultHeading = E('div', { 'class': 'librespeed-muted', 'style': 'margin-top:.35em' });
|
||||
this.resultNode = E('div', {});
|
||||
this.recentNode = E('div', {});
|
||||
this.recentCount = E('span', { 'class': 'librespeed-muted' });
|
||||
this.recentSubtitle = E('div', { 'class': 'librespeed-muted' },
|
||||
[ _('last 24 hours') ]);
|
||||
/* Built once and refilled: ui.Table gives sortable headers, the same
|
||||
* as the History page's table. */
|
||||
this.recentTable = new ui.Table([
|
||||
_('Time'), _('Server'),
|
||||
_('Download [Mbps]'), _('Upload [Mbps]'), _('Ping [ms]'), _('Jitter [ms]')
|
||||
], { id: 'librespeed-recent' });
|
||||
this.recentTableNode = this.recentTable.render();
|
||||
this.recentMetric = 'download_mbps';
|
||||
/* Two directions, one chart at a time: the page answers "how am I
|
||||
* doing" at a glance, and mixing both series would make that a
|
||||
* comparison instead. History is where series get combined. */
|
||||
this.recentSwitch = lscommon.switcher(this,
|
||||
lscommon.GROUPS.speed.map(k => {
|
||||
const m = lscommon.METRICS.find(x => x[0] == k);
|
||||
return [ k, m[1] ];
|
||||
}), this.recentMetric,
|
||||
function(v) {
|
||||
this.recentMetric = v;
|
||||
this.renderRecent(this.recentData);
|
||||
});
|
||||
this.button = E('button', { 'class': 'cbi-button cbi-button-action' },
|
||||
[ _('Start test') ]);
|
||||
|
||||
this.lastSeen = status.last_finished;
|
||||
|
||||
this.renderStatus(status);
|
||||
this.renderResult(result);
|
||||
this.renderRecent(recent);
|
||||
this.setBusy(!!status.running);
|
||||
|
||||
poll.add(L.bind(function() {
|
||||
return callStatus().then(L.bind(function(st) {
|
||||
st = st || {};
|
||||
this.renderStatus(st);
|
||||
this.setBusy(!!st.running);
|
||||
|
||||
/* Only refetch the result and the recent chart when a run has
|
||||
* actually finished, rather than on every tick. */
|
||||
if (!st.running && st.last_finished !== this.lastSeen) {
|
||||
this.lastSeen = st.last_finished;
|
||||
return Promise.all([
|
||||
callResult().then(L.bind(this.renderResult, this)),
|
||||
callHistory(Math.floor(Date.now() / 1000 - recentSeconds),
|
||||
undefined, 0)
|
||||
.then(L.bind(this.renderRecent, this))
|
||||
.catch(() => {})
|
||||
]);
|
||||
}
|
||||
}, this));
|
||||
}, this), pollInterval);
|
||||
|
||||
/* A third element, where a row has one, explains the value on hover
|
||||
* or focus. data-tooltip is LuCI's own mechanism -- a plain title
|
||||
* attribute leaves it to the browser, which shows nothing on some --
|
||||
* and the dotted underline is the affordance, since the theme styles
|
||||
* tooltips only inside form fields. The panel stays a compact status
|
||||
* list, and the reasoning still lives where the choice is made. */
|
||||
const kv = rows => E('table', { 'class': 'table' },
|
||||
rows.map(r => E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td librespeed-muted', 'style': 'width:45%' }, [ r[0] ]),
|
||||
E('td', { 'class': 'td' }, [
|
||||
r[2] ? E('span', { 'class': 'librespeed-hint', 'data-tooltip': r[2] }, [ r[1] ]) : r[1]
|
||||
])
|
||||
])));
|
||||
|
||||
/* 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 || [];
|
||||
const schedRows = [ [ _('Enabled'), sched.enabled ? _('Yes') : _('No') ] ];
|
||||
|
||||
if (sched.enabled) {
|
||||
schedRows.push([ _('Interval'), sched.interval || '1d' ]);
|
||||
if (next.length)
|
||||
schedRows.push([ _('Next run'), new Date(next[0] * 1000).toLocaleString() ]);
|
||||
}
|
||||
|
||||
/* The main card carries the result; the aside carries what the next
|
||||
* run will do. Plain sections and tables, so every theme renders it. */
|
||||
const aside = E('div', {}, [
|
||||
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'),
|
||||
config.scheme == 'https' ? _('HTTPS (forced)')
|
||||
: (config.scheme == 'http' ? _('HTTP (forced)')
|
||||
: _('server default')),
|
||||
config.scheme == 'http'
|
||||
? _('Unencrypted transfers, so the line is measured rather than the cipher.')
|
||||
: (config.scheme == 'https'
|
||||
? _('Encrypted transfers, which on hardware without AES acceleration may bound the result.')
|
||||
: _('Whichever scheme the server list provides.')) ]
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-section', 'style': 'margin:0' }, [
|
||||
E('h3', [ _('Schedule status') ]),
|
||||
kv(schedRows)
|
||||
])
|
||||
]);
|
||||
|
||||
const main = E('div', { 'class': 'cbi-section librespeed-center', 'style': 'margin:0' }, [
|
||||
this.alertNode,
|
||||
this.statusNode,
|
||||
this.resultHeading,
|
||||
this.resultNode,
|
||||
E('div', { 'style': 'margin-top:1em' }, [ this.button ])
|
||||
]);
|
||||
|
||||
const recentSection = E('div', { 'class': 'cbi-section', 'style': 'margin:1.5em 0 0 0' }, [
|
||||
/* Heading and count on one line, the controls on their own below:
|
||||
* the metric buttons and the window they cover belong together,
|
||||
* the way the History toolbar pairs series with range. */
|
||||
E('div', { 'class': 'librespeed-footer', 'style': 'margin:0; align-items:baseline' }, [
|
||||
E('h3', { 'style': 'margin:0' }, [ _('Recent history') ]),
|
||||
this.recentCount
|
||||
]),
|
||||
E('div', { 'class': 'librespeed-toolbar', 'style': 'margin:.5em 0 .75em' }, [
|
||||
this.recentSwitch,
|
||||
this.recentSubtitle
|
||||
]),
|
||||
this.recentNode
|
||||
]);
|
||||
|
||||
return E([], [
|
||||
lscommon.cssLink(),
|
||||
E('h2', [ _('LibreSpeed') ]),
|
||||
E('h3', [ _('Router speed test') ]),
|
||||
E('p', { 'class': 'librespeed-muted' },
|
||||
[ _('Measures the internet connection from this router.') ]),
|
||||
E('div', { 'class': 'librespeed-columns' }, [
|
||||
E('div', {}, [ main, recentSection ]),
|
||||
aside
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -0,0 +1,593 @@
|
||||
msgid ""
|
||||
msgstr "Content-Type: text/plain; charset=UTF-8"
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:272
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:223
|
||||
msgid "%d measurements"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:430
|
||||
msgid "%s elapsed"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:124
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:136
|
||||
msgid "1 year"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:30
|
||||
msgid "1y"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:137
|
||||
msgid "2 years"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:27
|
||||
msgid "24h"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:122
|
||||
msgid "30 days"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:29
|
||||
msgid "30d"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:121
|
||||
msgid "7 days"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:28
|
||||
msgid "7d"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:123
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:135
|
||||
msgid "90 days"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:157
|
||||
msgid ""
|
||||
"A daily measurement runs at a time drawn when the schedule is applied; "
|
||||
"saving draws it anew."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:31
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:82
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:95
|
||||
msgid ""
|
||||
"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."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:128
|
||||
msgid "Archive path"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:134
|
||||
msgid "Archive retention"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:521
|
||||
msgid "Completed"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:129
|
||||
msgid ""
|
||||
"Completed days are reduced to daily minimum, average and maximum and "
|
||||
"appended here once a day, so measurements since the last flush may be lost "
|
||||
"after an unexpected power loss. Leave empty to keep no persistent history."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:405
|
||||
msgid "Connecting to the test server…"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:327
|
||||
msgid "Could not start the measurement: %s"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:67
|
||||
msgid "Daily"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:221
|
||||
msgid "Daily minimum, average and maximum."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:71
|
||||
msgid "Days"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:251
|
||||
msgid "Declining — the last measurement is %d%% below the 24-hour average."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:11
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:92
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:491
|
||||
msgid "Download"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:312
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:563
|
||||
msgid "Download [Mbps]"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:58
|
||||
msgid "Enable"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:628
|
||||
msgid "Enabled"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:652
|
||||
msgid ""
|
||||
"Encrypted transfers, which on hardware without AES acceleration may bound "
|
||||
"the result."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:66
|
||||
msgid "Every 12 hours"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:62
|
||||
msgid "Every 15 minutes"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:63
|
||||
msgid "Every 30 minutes"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:65
|
||||
msgid "Every 6 hours"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:381
|
||||
msgid "Export CSV"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:384
|
||||
msgid "Export JSON"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:77
|
||||
msgid "Friday"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json:3
|
||||
msgid "Grant access to LibreSpeed measurements"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:647
|
||||
msgid "HTTP (forced)"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:646
|
||||
msgid "HTTPS (forced)"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:147
|
||||
msgid "Hide this series"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:110
|
||||
#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:25
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:116
|
||||
msgid "History stored in /tmp is lost when the router reboots."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:64
|
||||
msgid "Hourly"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:94
|
||||
msgid "Hours"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:311
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:348
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:25
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:509
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:642
|
||||
msgid "Interface"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:61
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:631
|
||||
msgid "Interval"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:14
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:499
|
||||
msgid "Jitter"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:312
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:563
|
||||
msgid "Jitter [ms]"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:112
|
||||
msgid "Keep history"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:449
|
||||
msgid "Last measurement failed"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:375
|
||||
msgid "Last successful measurement"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:318
|
||||
msgid "Latency"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:72
|
||||
msgid "Leave empty to run every day."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:684
|
||||
#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:3
|
||||
msgid "LibreSpeed"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:393
|
||||
msgid "LibreSpeed – History"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:21
|
||||
msgid "LibreSpeed – Settings"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:26
|
||||
msgid "Logical interface the measurement is bound to."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:23
|
||||
msgid "Measurement"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:376
|
||||
msgid "Measurements"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:687
|
||||
msgid "Measures the internet connection from this router."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:73
|
||||
msgid "Monday"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:36
|
||||
msgid "Must be a server id or 'auto'"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:46
|
||||
msgid "Must be an http(s) URL"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:286
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:633
|
||||
msgid "Next run"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:628
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:218
|
||||
msgid "No data for the selected metric."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:278
|
||||
msgid "No data in the last 24 hours."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:466
|
||||
msgid "No measurement yet."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:194
|
||||
msgid "No measurements match the current filters."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:368
|
||||
msgid "No measurements yet"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:162
|
||||
msgid ""
|
||||
"No schedule is active. Enable it above and apply to draw the measurement "
|
||||
"time."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:31
|
||||
msgid ""
|
||||
"Numeric server id from the LibreSpeed server list, or 'auto' to pick the "
|
||||
"closest."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:50
|
||||
msgid ""
|
||||
"On routers without AES acceleration, TLS itself can bound the result — "
|
||||
"forcing plain HTTP then measures the line rather than the cipher."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:281
|
||||
msgid "Page %d of %d"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:115
|
||||
msgid "Path"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:13
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:91
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:498
|
||||
msgid "Ping"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:312
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:563
|
||||
msgid "Ping [ms]"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:279
|
||||
msgid "Previous"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:311
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:49
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:518
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:645
|
||||
msgid "Protocol"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:673
|
||||
msgid "Recent history"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:120
|
||||
msgid "Retention"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:685
|
||||
msgid "Router speed test"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:193
|
||||
msgid "Run a speed test to start building your connection history."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:59
|
||||
msgid ""
|
||||
"Runs a measurement from cron at the chosen interval. Note that a measurement "
|
||||
"saturates the connection while it runs."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:78
|
||||
msgid "Saturday"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:657
|
||||
msgid "Schedule status"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:56
|
||||
msgid "Scheduled measurements"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:311
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:346
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:30
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:507
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:562
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:643
|
||||
msgid "Server"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:39
|
||||
msgid "Server list URL"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:34
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:147
|
||||
msgid "Show this series"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:318
|
||||
msgid "Speed"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:175
|
||||
msgid "Stable over the selected period."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:257
|
||||
msgid "Stable — averaged %d Mbps, ranging from %d to %d Mbps."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:362
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:349
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:580
|
||||
msgid "Start test"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:349
|
||||
msgid "Stop test"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:79
|
||||
msgid "Sunday"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json:16
|
||||
msgid "Test"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:640
|
||||
msgid "Test configuration"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:106
|
||||
msgid "The window must not cross midnight"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:76
|
||||
msgid "Thursday"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:311
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:562
|
||||
msgid "Time"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:74
|
||||
msgid "Tuesday"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:40
|
||||
msgid ""
|
||||
"URL of a LibreSpeed server list in JSON, from a self-hosted deployment: "
|
||||
"either a static file such as https://example.org/servers.json or a generator "
|
||||
"such as https://example.org/backend/servers.php. Leave empty for the "
|
||||
"official list."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:650
|
||||
msgid "Unencrypted transfers, so the line is measured rather than the cipher."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:150
|
||||
msgid "Upcoming measurements"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:12
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:93
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:492
|
||||
msgid "Upload"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:312
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:563
|
||||
msgid "Upload [Mbps]"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:104
|
||||
msgid "Use an hour (0-23) or a range like 2-5"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:254
|
||||
msgid "Variable — ranged from %d to %d Mbps."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:319
|
||||
msgid "View full history »"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:540
|
||||
msgid "View result"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:75
|
||||
msgid "Wednesday"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:653
|
||||
msgid "Whichever scheme the server list provides."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:628
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:644
|
||||
msgid "automatic (nearest)"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:293
|
||||
msgid "average"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js:328
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:154
|
||||
msgid "avg"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:294
|
||||
msgid "best"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:292
|
||||
msgid "current"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:165
|
||||
msgid "declining"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:516
|
||||
msgid "encrypted (HTTPS)"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:53
|
||||
msgid "force HTTP"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:52
|
||||
msgid "force HTTPS"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:165
|
||||
msgid "highly variable"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:558
|
||||
msgid "last 24 hours"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:516
|
||||
msgid "plain HTTP"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:360
|
||||
msgid "server %s"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js:51
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:648
|
||||
msgid "server default"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:165
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:172
|
||||
msgid "stable"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js:122
|
||||
msgid "vs previous period"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js:295
|
||||
msgid "worst"
|
||||
msgstr ""
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"admin/network/librespeed": {
|
||||
"title": "LibreSpeed",
|
||||
"order": 70,
|
||||
"action": {
|
||||
"type": "alias",
|
||||
"path": "admin/network/librespeed/test"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-librespeed" ],
|
||||
"uci": { "librespeed": true }
|
||||
}
|
||||
},
|
||||
|
||||
"admin/network/librespeed/test": {
|
||||
"title": "Test",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "librespeed/test"
|
||||
}
|
||||
},
|
||||
|
||||
"admin/network/librespeed/history": {
|
||||
"title": "History",
|
||||
"order": 20,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "librespeed/history"
|
||||
}
|
||||
},
|
||||
|
||||
"admin/network/librespeed/settings": {
|
||||
"title": "Settings",
|
||||
"order": 30,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "librespeed/settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"luci-app-librespeed": {
|
||||
"description": "Grant access to LibreSpeed measurements",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"librespeed": [ "status", "result", "history", "config" ]
|
||||
},
|
||||
"uci": [ "librespeed" ]
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"librespeed": [ "start", "stop" ]
|
||||
},
|
||||
"uci": [ "librespeed" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user