From 7c3ffdd7614b27671f4ca3590ea25d605cc28684 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Aug 2026 20:43:52 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Sync=202026-08-19=2020:43:52?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- librespeed-common/LICENSE | 355 +++++++++ librespeed-common/Makefile | 77 ++ librespeed-common/files/librespeed-aggregate | 184 +++++ librespeed-common/files/librespeed-run | 396 ++++++++++ librespeed-common/files/librespeed.config | 18 + librespeed-common/files/librespeed.init | 120 +++ librespeed-common/files/librespeed.uc | 322 ++++++++ librespeed-common/test.sh | 26 + luci-app-librespeed/Makefile | 11 + .../resources/librespeed/common.js | 506 +++++++++++++ .../resources/librespeed/librespeed.css | 315 ++++++++ .../resources/view/librespeed/history.js | 403 ++++++++++ .../resources/view/librespeed/settings.js | 170 +++++ .../resources/view/librespeed/test.js | 701 ++++++++++++++++++ .../po/templates/librespeed.pot | 593 +++++++++++++++ .../luci/menu.d/luci-app-librespeed.json | 41 + .../share/rpcd/acl.d/luci-app-librespeed.json | 17 + 17 files changed, 4255 insertions(+) create mode 100644 librespeed-common/LICENSE create mode 100644 librespeed-common/Makefile create mode 100755 librespeed-common/files/librespeed-aggregate create mode 100755 librespeed-common/files/librespeed-run create mode 100644 librespeed-common/files/librespeed.config create mode 100644 librespeed-common/files/librespeed.init create mode 100644 librespeed-common/files/librespeed.uc create mode 100755 librespeed-common/test.sh create mode 100644 luci-app-librespeed/Makefile create mode 100644 luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js create mode 100644 luci-app-librespeed/htdocs/luci-static/resources/librespeed/librespeed.css create mode 100644 luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js create mode 100644 luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js create mode 100644 luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js create mode 100644 luci-app-librespeed/po/templates/librespeed.pot create mode 100644 luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json create mode 100644 luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json diff --git a/librespeed-common/LICENSE b/librespeed-common/LICENSE new file mode 100644 index 00000000..4d90dc14 --- /dev/null +++ b/librespeed-common/LICENSE @@ -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. + + + Copyright (C) + + 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. + + , 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. diff --git a/librespeed-common/Makefile b/librespeed-common/Makefile new file mode 100644 index 00000000..e5952710 --- /dev/null +++ b/librespeed-common/Makefile @@ -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 +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)) diff --git a/librespeed-common/files/librespeed-aggregate b/librespeed-common/files/librespeed-aggregate new file mode 100755 index 00000000..62911acc --- /dev/null +++ b/librespeed-common/files/librespeed-aggregate @@ -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}`]); diff --git a/librespeed-common/files/librespeed-run b/librespeed-common/files/librespeed-run new file mode 100755 index 00000000..8de9059d --- /dev/null +++ b/librespeed-common/files/librespeed-run @@ -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 diff --git a/librespeed-common/files/librespeed.config b/librespeed-common/files/librespeed.config new file mode 100644 index 00000000..3fa1afa0 --- /dev/null +++ b/librespeed-common/files/librespeed.config @@ -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' diff --git a/librespeed-common/files/librespeed.init b/librespeed-common/files/librespeed.init new file mode 100644 index 00000000..8b9cb959 --- /dev/null +++ b/librespeed-common/files/librespeed.init @@ -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 +} diff --git a/librespeed-common/files/librespeed.uc b/librespeed-common/files/librespeed.uc new file mode 100644 index 00000000..261d0d69 --- /dev/null +++ b/librespeed-common/files/librespeed.uc @@ -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 }; diff --git a/librespeed-common/test.sh b/librespeed-common/test.sh new file mode 100755 index 00000000..a081e813 --- /dev/null +++ b/librespeed-common/test.sh @@ -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" diff --git a/luci-app-librespeed/Makefile b/luci-app-librespeed/Makefile new file mode 100644 index 00000000..74dad214 --- /dev/null +++ b/luci-app-librespeed/Makefile @@ -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 + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js b/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js new file mode 100644 index 00000000..b327ae27 --- /dev/null +++ b/luci-app-librespeed/htdocs/luci-static/resources/librespeed/common.js @@ -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 '' + .format(L, y, W - R, y) + + '%s' + .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 += '' + .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 += '' + .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 += '' + .format(ci, L, sc.ys(avg), W - R, sc.ys(avg)); + body += 'avg %s' + .format(W - R, sc.ys(avg) - 4, avg.toFixed(avg >= 100 ? 0 : 1)); + + body += (valid.length > 1) + ? ''.format(ci, pts) + : '' + .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 => + '' + .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) + ? '%h' + .format((L + W - R) / 2, H - 6, s2) + : ''; + + const unit = metricOf(series[0]) ? metricOf(series[0])[2] : ''; + + const chart = E('div', {}); + chart.innerHTML = + '' + .format(W, H) + + grid + body + + '%h' + .format(L, H - 6, s1) + + midLabel + + '%h (%s)' + .format(W - R, H - 6, s3, unit) + + ''; + 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'); + } +}); diff --git a/luci-app-librespeed/htdocs/luci-static/resources/librespeed/librespeed.css b/luci-app-librespeed/htdocs/luci-static/resources/librespeed/librespeed.css new file mode 100644 index 00000000..2b5dfea5 --- /dev/null +++ b/luci-app-librespeed/htdocs/luci-static/resources/librespeed/librespeed.css @@ -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; +} diff --git a/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js b/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js new file mode 100644 index 00000000..9df8df22 --- /dev/null +++ b/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/history.js @@ -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 +}); diff --git a/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js b/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js new file mode 100644 index 00000000..0199d5b1 --- /dev/null +++ b/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/settings.js @@ -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; + }); + } +}); diff --git a/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js b/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js new file mode 100644 index 00000000..205a5b45 --- /dev/null +++ b/luci-app-librespeed/htdocs/luci-static/resources/view/librespeed/test.js @@ -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 += '' + .format(t1[0], t1[1], t2[0], t2[1]); + + if (major) { + const p = polar(cx, cy, r - 28, a); + marks += '%s' + .format(p[0], p[1] + 3, gaugeLabel(top * i / 10)); + } + } + + const tip = polar(cx, cy, r - 20, start - amt * span); + + return '' + + ''.format(path) + + '' + .format(ci, path, amt * len, len) + + marks + + '' + .format(cx, cy, tip[0], tip[1]) + + ''.format(cx, cy) + + '%s' + .format((typeof mbps == 'number') ? '%.2f'.format(mbps) : '–') + + 'Mbps' + + ''; +} + +/* 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 +}); diff --git a/luci-app-librespeed/po/templates/librespeed.pot b/luci-app-librespeed/po/templates/librespeed.pot new file mode 100644 index 00000000..a5f08e76 --- /dev/null +++ b/luci-app-librespeed/po/templates/librespeed.pot @@ -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 "" diff --git a/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json b/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json new file mode 100644 index 00000000..d29f6be0 --- /dev/null +++ b/luci-app-librespeed/root/usr/share/luci/menu.d/luci-app-librespeed.json @@ -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" + } + } +} diff --git a/luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json b/luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json new file mode 100644 index 00000000..1fab601a --- /dev/null +++ b/luci-app-librespeed/root/usr/share/rpcd/acl.d/luci-app-librespeed.json @@ -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" ] + } + } +}