feat: add netstable tester
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
/data/*.jsonl
|
||||
/dist/
|
||||
/netstable
|
||||
*.jsonl
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,225 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
==========================================================================
|
||||
The following license applies to the ip2region library
|
||||
--------------------------------------------------------------------------
|
||||
Copyright (c) 2015 Lionsoul<chenxin619315@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,45 @@
|
||||
APP := netstable
|
||||
DIST := dist
|
||||
VERSION ?= dev
|
||||
PLATFORMS := linux/amd64 linux/arm64
|
||||
|
||||
.PHONY: test build package release update-ip-db clean
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
build:
|
||||
mkdir -p $(DIST)
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(APP) ./cmd/netstable
|
||||
|
||||
package:
|
||||
mkdir -p $(DIST)
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o $(DIST)/$(APP)-linux-amd64 ./cmd/netstable
|
||||
mkdir -p $(DIST)/$(APP)-$(VERSION)-linux-amd64
|
||||
cp $(DIST)/$(APP)-linux-amd64 $(DIST)/$(APP)-$(VERSION)-linux-amd64/$(APP)
|
||||
cp README.md THIRD_PARTY.md deploy/netstable.service $(DIST)/$(APP)-$(VERSION)-linux-amd64/
|
||||
cp -R LICENSES $(DIST)/$(APP)-$(VERSION)-linux-amd64/
|
||||
COPYFILE_DISABLE=1 tar -czf $(DIST)/$(APP)-$(VERSION)-linux-amd64.tar.gz -C $(DIST)/$(APP)-$(VERSION)-linux-amd64 .
|
||||
|
||||
release: clean test
|
||||
mkdir -p $(DIST)
|
||||
set -e; \
|
||||
for platform in $(PLATFORMS); do \
|
||||
os=$${platform%/*}; \
|
||||
arch=$${platform#*/}; \
|
||||
name="$(APP)-$(VERSION)-$${os}-$${arch}"; \
|
||||
mkdir -p "$(DIST)/$${name}"; \
|
||||
CGO_ENABLED=0 GOOS=$${os} GOARCH=$${arch} go build -trimpath -ldflags "-s -w" -o "$(DIST)/$${name}/$(APP)" ./cmd/netstable; \
|
||||
cp README.md THIRD_PARTY.md deploy/netstable.service "$(DIST)/$${name}/"; \
|
||||
cp -R LICENSES "$(DIST)/$${name}/"; \
|
||||
COPYFILE_DISABLE=1 tar -czf "$(DIST)/$${name}.tar.gz" -C "$(DIST)/$${name}" .; \
|
||||
done
|
||||
cd $(DIST) && shasum -a 256 *.tar.gz > checksums.txt
|
||||
|
||||
update-ip-db:
|
||||
mkdir -p data
|
||||
curl -L --fail --retry 3 -o data/ip2region_v4.xdb https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/ip2region_v4.xdb
|
||||
shasum -a 256 data/ip2region_v4.xdb
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST)
|
||||
@@ -0,0 +1,133 @@
|
||||
# NetStable
|
||||
|
||||
NetStable 是一个独立的 Go Web 测速项目,用于测试“打开网页的用户浏览器”到“部署服务器”的上传、下载和延迟。用户打开网页即可开始测试,页面会实时显示下载速度、上传速度、延迟、稳定评分和历史时序曲线。
|
||||
|
||||
## 功能
|
||||
|
||||
- 同一时间只允许一个用户真正进行测速。
|
||||
- 其他用户发起请求时返回“有其他用户正在测速,请稍等”。
|
||||
- 启动时可设置程序理论最高带宽,例如 `-bandwidth-limit-mbps 30` 将测试流量限制到 30 Mbps。
|
||||
- 页面展示当前设置的理论最高限值。
|
||||
- 一次测试按阶段执行:先连续下载指定时长,再连续上传同样时长。例如页面填 `30`,就是下载 30 秒、上传 30 秒。
|
||||
- 保存每次测试的完整样本曲线、摘要、脱敏后的用户 IP、地区和运营商信息;ISP 缺失时显示“未知运营商”。
|
||||
- 所有历史测试用户数据通过网页和 `/api/records` 提供。
|
||||
- 历史时序图展示不同时段的平均下载速度变化。
|
||||
- 页面通过 SSE 心跳维持一条辅助长连接,用于显示连接状态和服务配置。
|
||||
- 内置 GitHub 开源 `ip2region_v4.xdb` IPv4 数据库,优先离线解析地区和 ISP,避免外部 API 限流。
|
||||
- 静态页面嵌入二进制,部署时只需要上传一个可执行文件。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go run ./cmd/netstable -listen 127.0.0.1:18080 -data data/records.jsonl -bandwidth-limit-mbps 30
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:18080/`。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
make test
|
||||
make build
|
||||
```
|
||||
|
||||
生成文件位于 `dist/netstable`。
|
||||
|
||||
更新内置 IP 数据库:
|
||||
|
||||
```bash
|
||||
make update-ip-db
|
||||
```
|
||||
|
||||
## Release 二进制
|
||||
|
||||
线上可以通过 Gitea Release 发布可直接下载的二进制压缩包,不建议把二进制提交到 Git 仓库。
|
||||
|
||||
仓库设置为 public 后,用户不需要登录即可下载 Release 附件。把下面命令中的 `https://gitea.example.com/owner/netstable` 替换成实际 public 仓库地址即可一键下载并启动:
|
||||
|
||||
```bash
|
||||
VERSION=v0.1.0 REPO=https://gitea.example.com/owner/netstable sh -c 'set -e; arch=$(uname -m); case "$arch" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; *) echo "unsupported arch: $arch"; exit 1;; esac; tmp=$(mktemp -d); curl -fsSL "$REPO/releases/download/$VERSION/netstable-$VERSION-linux-$arch.tar.gz" | tar -xz -C "$tmp"; sudo install -m 0755 "$tmp/netstable" /usr/local/bin/netstable; sudo mkdir -p /var/lib/netstable; sudo /usr/local/bin/netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -bandwidth-limit-mbps 30'
|
||||
```
|
||||
|
||||
生成 Linux release 附件:
|
||||
|
||||
```bash
|
||||
make release VERSION=v0.1.0
|
||||
```
|
||||
|
||||
生成内容:
|
||||
|
||||
- `dist/netstable-v0.1.0-linux-amd64.tar.gz`
|
||||
- `dist/netstable-v0.1.0-linux-arm64.tar.gz`
|
||||
- `dist/checksums.txt`
|
||||
|
||||
在 Gitea 的仓库页面创建 Release,标签填 `v0.1.0`,然后上传上述附件。用户可以直接下载后运行:
|
||||
|
||||
```bash
|
||||
tar -xzf netstable-v0.1.0-linux-amd64.tar.gz
|
||||
chmod +x netstable
|
||||
./netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -bandwidth-limit-mbps 30
|
||||
```
|
||||
|
||||
## Linux 部署
|
||||
|
||||
```bash
|
||||
scp dist/netstable root@SERVER:/usr/local/bin/netstable
|
||||
ssh root@SERVER 'mkdir -p /var/lib/netstable && chmod +x /usr/local/bin/netstable'
|
||||
```
|
||||
|
||||
可直接启动:
|
||||
|
||||
```bash
|
||||
/usr/local/bin/netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -bandwidth-limit-mbps 30
|
||||
```
|
||||
|
||||
也可以使用 `deploy/netstable.service`:
|
||||
|
||||
```bash
|
||||
scp deploy/netstable.service root@SERVER:/etc/systemd/system/netstable.service
|
||||
ssh root@SERVER 'systemctl daemon-reload && systemctl enable --now netstable'
|
||||
```
|
||||
|
||||
## CLI 测试
|
||||
|
||||
同一个二进制也可以作为客户端,在其他服务器上直接测试到部署节点的上传、下载和延迟。CLI 会复用 Web API,所以仍然遵守“同一时间只允许一个用户测速”的锁。
|
||||
|
||||
```bash
|
||||
./netstable client -server http://103.46.93.38:18080 -duration 30
|
||||
```
|
||||
|
||||
输出 JSON:
|
||||
|
||||
```bash
|
||||
./netstable client -server http://103.46.93.38:18080 -duration 30 -json
|
||||
```
|
||||
|
||||
常用参数:
|
||||
|
||||
- `-server`: NetStable 服务端地址。
|
||||
- `-duration`: 每阶段时长,先下载同样秒数,再上传同样秒数。
|
||||
- `-target`: 保存到记录里的目标标签,默认使用 `-server` 的 host。
|
||||
- `-download-bytes`: 每次下载请求大小,默认 4 MiB。
|
||||
- `-upload-bytes`: 每次上传请求大小,默认 2 MiB。
|
||||
- `-quiet`: 不输出每个样本,只输出最终摘要。
|
||||
|
||||
## 参数
|
||||
|
||||
- `-listen`: HTTP 监听地址,默认 `:8080`;部署示例使用高端口 `18080`。
|
||||
- `-data`: JSONL 记录文件,默认 `data/records.jsonl`。
|
||||
- `-static`: 可选静态资源目录;为空时使用嵌入资源。
|
||||
- `-geo-base`: 在线 IP 归属地 API fallback 地址,默认 `https://ipapi.co`,并在默认源限流时自动尝试 `https://ipinfo.io`;内置 `ip2region` 会优先查询,设为空可关闭在线 fallback。
|
||||
- `-geo-timeout`: IP 归属地查询超时,默认 `2s`。
|
||||
- `-trust-proxy-headers`: 是否信任 `CF-Connecting-IP`、`X-Real-IP`、`X-Forwarded-For`。直连公网时不要开启;放在可信反向代理后面时开启。
|
||||
- `-bandwidth-limit-mbps`: 程序理论最高带宽限制,单位 Mbps。示例:服务器 50 Mbps,希望测速程序最多使用 30 Mbps,则设置为 `30`;默认 `0` 表示不限速。
|
||||
|
||||
## Gitea
|
||||
|
||||
仓库可以直接推送到 Gitea。设置 remote 后执行:
|
||||
|
||||
```bash
|
||||
git remote add origin <gitea-repo-url>
|
||||
git push -u origin codex/go-network-stability-tester
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
# Third Party Notices
|
||||
|
||||
## ip2region
|
||||
|
||||
- Project: https://github.com/lionsoul2014/ip2region
|
||||
- License: Apache-2.0, see `LICENSES/ip2region-LICENSE.md`.
|
||||
- Embedded database: `data/ip2region_v4.xdb`
|
||||
- Source URL: https://raw.githubusercontent.com/lionsoul2014/ip2region/master/data/ip2region_v4.xdb
|
||||
- SHA256: `cabf0493883e94015da95ce9ef22b514d9494eb47f66b22942c7710c5d6720be`
|
||||
|
||||
The embedded IPv4 database is used for offline client region and ISP lookup before any online fallback resolver is attempted.
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
ipdata "speedtest/data"
|
||||
appclient "speedtest/internal/client"
|
||||
"speedtest/internal/geo"
|
||||
"speedtest/internal/probe"
|
||||
recordstore "speedtest/internal/store"
|
||||
appweb "speedtest/internal/web"
|
||||
webassets "speedtest/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
args := os.Args[1:]
|
||||
if len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "client":
|
||||
runClient(args[1:])
|
||||
return
|
||||
case "serve":
|
||||
runServer(args[1:])
|
||||
return
|
||||
}
|
||||
}
|
||||
runServer(args)
|
||||
}
|
||||
|
||||
func runServer(args []string) {
|
||||
flags := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
listen := flags.String("listen", ":8080", "HTTP listen address")
|
||||
dataPath := flags.String("data", "data/records.jsonl", "JSONL record file")
|
||||
staticDir := flags.String("static", "", "optional static asset directory; embedded assets are used when empty")
|
||||
geoBase := flags.String("geo-base", "https://ipapi.co", "online IP geolocation fallback API base URL; set empty to disable online fallback")
|
||||
geoTimeout := flags.Duration("geo-timeout", 2*time.Second, "IP geolocation request timeout")
|
||||
trustProxyHeaders := flags.Bool("trust-proxy-headers", false, "trust CF-Connecting-IP, X-Real-IP, and X-Forwarded-For headers")
|
||||
bandwidthLimitMbps := flags.Float64("bandwidth-limit-mbps", 0, "theoretical program bandwidth limit in Mbps; 0 means unlimited")
|
||||
_ = flags.Parse(args)
|
||||
|
||||
store := recordstore.New(*dataPath)
|
||||
var resolvers []appweb.ClientResolver
|
||||
if db, err := ipdata.FileSystem().ReadFile("ip2region_v4.xdb"); err != nil {
|
||||
log.Printf("embedded ip2region database unavailable: %v", err)
|
||||
} else if localResolver, err := geo.NewXDBResolver(db); err != nil {
|
||||
log.Printf("embedded ip2region database failed to load: %v", err)
|
||||
} else {
|
||||
resolvers = append(resolvers, localResolver.Resolve)
|
||||
}
|
||||
if *geoBase != "" {
|
||||
if *geoBase == "https://ipapi.co" {
|
||||
resolvers = append(resolvers, geo.Chain(
|
||||
geo.New(*geoBase, *geoTimeout),
|
||||
geo.New("https://ipinfo.io", *geoTimeout),
|
||||
))
|
||||
} else {
|
||||
resolvers = append(resolvers, geo.New(*geoBase, *geoTimeout).Resolve)
|
||||
}
|
||||
}
|
||||
resolver := chainResolvers(resolvers...)
|
||||
|
||||
options := appweb.Options{
|
||||
Store: store,
|
||||
Runner: appweb.DefaultRunner,
|
||||
ClientResolver: resolver,
|
||||
TrustProxyHeaders: *trustProxyHeaders,
|
||||
BandwidthLimitMbps: *bandwidthLimitMbps,
|
||||
StaticDir: *staticDir,
|
||||
}
|
||||
if *staticDir == "" {
|
||||
options.StaticFS = webassets.FileSystem()
|
||||
}
|
||||
|
||||
server := appweb.New(options)
|
||||
httpServer := http.Server{
|
||||
Addr: *listen,
|
||||
Handler: server.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("netstable listening on %s", *listen)
|
||||
log.Printf("records file: %s", *dataPath)
|
||||
if *bandwidthLimitMbps > 0 {
|
||||
log.Printf("bandwidth limit: %.2f Mbps", *bandwidthLimitMbps)
|
||||
} else {
|
||||
log.Printf("bandwidth limit: unlimited")
|
||||
}
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runClient(args []string) {
|
||||
flags := flag.NewFlagSet("client", flag.ExitOnError)
|
||||
serverURL := flags.String("server", "", "NetStable server URL, for example http://103.46.93.38:18080")
|
||||
target := flags.String("target", "", "target label saved in records; defaults to server host")
|
||||
durationSeconds := flags.Int("duration", 30, "seconds for each phase: download first, then upload")
|
||||
timeoutMillis := flags.Int("timeout", 3000, "request timeout in milliseconds")
|
||||
downloadBytes := flags.Int("download-bytes", appclient.DefaultDownloadBytes, "bytes per download request")
|
||||
uploadBytes := flags.Int("upload-bytes", appclient.DefaultUploadBytes, "bytes per upload request")
|
||||
jsonOutput := flags.Bool("json", false, "print summary as JSON")
|
||||
quiet := flags.Bool("quiet", false, "suppress sample progress output")
|
||||
_ = flags.Parse(args)
|
||||
|
||||
if *serverURL == "" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "client requires -server, for example: netstable client -server http://103.46.93.38:18080")
|
||||
flags.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
options := appclient.Options{
|
||||
ServerURL: *serverURL,
|
||||
Target: *target,
|
||||
Duration: time.Duration(*durationSeconds) * time.Second,
|
||||
Timeout: time.Duration(*timeoutMillis) * time.Millisecond,
|
||||
DownloadBytes: *downloadBytes,
|
||||
UploadBytes: *uploadBytes,
|
||||
}
|
||||
if !*quiet && !*jsonOutput {
|
||||
options.OnSample = func(sample probe.Sample) {
|
||||
printSample(os.Stderr, sample)
|
||||
}
|
||||
}
|
||||
summary, err := appclient.Run(context.Background(), options)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "client failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *jsonOutput {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(summary)
|
||||
return
|
||||
}
|
||||
printSummary(os.Stdout, summary)
|
||||
}
|
||||
|
||||
func printSample(out *os.File, sample probe.Sample) {
|
||||
status := "OK"
|
||||
if !sample.Success {
|
||||
status = "FAIL"
|
||||
}
|
||||
switch sample.Kind {
|
||||
case "latency":
|
||||
_, _ = fmt.Fprintf(out, "%s %s %.1f ms %s\n", sample.Kind, status, sample.LatencyMS, sample.Error)
|
||||
case "download", "upload":
|
||||
_, _ = fmt.Fprintf(out, "%s %s %.2f Mbps %d bytes %s\n", sample.Kind, status, sample.Mbps, sample.Bytes, sample.Error)
|
||||
default:
|
||||
_, _ = fmt.Fprintf(out, "%s %s %s\n", sample.Kind, status, sample.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func printSummary(out *os.File, summary probe.Summary) {
|
||||
_, _ = fmt.Fprintf(out, "目标: %s\n", summary.Target)
|
||||
_, _ = fmt.Fprintf(out, "评分: %.2f\n", summary.Score)
|
||||
_, _ = fmt.Fprintf(out, "下载: avg %.2f Mbps, max %.2f Mbps\n", summary.AvgDownloadMbps, summary.MaxDownloadMbps)
|
||||
_, _ = fmt.Fprintf(out, "上传: avg %.2f Mbps, max %.2f Mbps\n", summary.AvgUploadMbps, summary.MaxUploadMbps)
|
||||
_, _ = fmt.Fprintf(out, "延迟: avg %.2f ms, min %.2f ms, max %.2f ms, jitter %.2f ms\n", summary.AvgLatencyMS, summary.MinLatencyMS, summary.MaxLatencyMS, summary.JitterMS)
|
||||
_, _ = fmt.Fprintf(out, "样本: %d, 成功: %d, 失败: %d, 丢失率: %.2f%%\n", summary.Samples, summary.Successes, summary.Failures, summary.PacketLossPct)
|
||||
}
|
||||
|
||||
func chainResolvers(resolvers ...appweb.ClientResolver) appweb.ClientResolver {
|
||||
return func(ctx context.Context, ip string) (recordstore.ClientInfo, error) {
|
||||
var firstErr error
|
||||
var best recordstore.ClientInfo
|
||||
for _, resolver := range resolvers {
|
||||
if resolver == nil {
|
||||
continue
|
||||
}
|
||||
info, err := resolver(ctx, ip)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if info.ISP != "" {
|
||||
return info, nil
|
||||
}
|
||||
if best.IP == "" {
|
||||
best = info
|
||||
}
|
||||
}
|
||||
if best.IP != "" {
|
||||
return best, nil
|
||||
}
|
||||
return recordstore.ClientInfo{}, firstErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package data
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed ip2region_v4.xdb
|
||||
var files embed.FS
|
||||
|
||||
func FileSystem() embed.FS {
|
||||
return files
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=NetStable network stability tester
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -bandwidth-limit-mbps 30
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,36 @@
|
||||
# NetStable <version>
|
||||
|
||||
## 下载
|
||||
|
||||
- `netstable-<version>-linux-amd64.tar.gz`: x86_64 Linux 服务器
|
||||
- `netstable-<version>-linux-arm64.tar.gz`: ARM64 Linux 服务器
|
||||
- `checksums.txt`: SHA256 校验和
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
tar -xzf netstable-<version>-linux-amd64.tar.gz
|
||||
chmod +x netstable
|
||||
./netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -bandwidth-limit-mbps 30
|
||||
```
|
||||
|
||||
CLI 客户端测试:
|
||||
|
||||
```bash
|
||||
./netstable client -server http://<server-ip>:18080 -duration 30
|
||||
```
|
||||
|
||||
## 校验
|
||||
|
||||
```bash
|
||||
shasum -a 256 -c checksums.txt
|
||||
```
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 单用户实时测速锁。
|
||||
- 理论最高带宽限制和页面展示。
|
||||
- 保存脱敏后的用户 IP、地区、运营商、测试摘要和完整样本曲线。
|
||||
- SSE 心跳辅助连接用于显示连接状态。
|
||||
- 内置 ip2region IPv4 数据库,优先离线解析地区和运营商。
|
||||
- 嵌入式 Web 页面,无需 Node、PHP 或数据库。
|
||||
@@ -0,0 +1,117 @@
|
||||
# Network Stability Tester Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a self-contained Go web app that tests browser-to-server upload/download speed and latency in real time, allows only one active tester, supports a startup Mbps limit, and stores all user records with client metadata and sample curves.
|
||||
|
||||
**Architecture:** A Go HTTP server serves an embedded static dashboard, JSON APIs, bandwidth-limited generated download traffic, upload sink traffic, and completion persistence. Rate limiting, IP metadata lookup, global active-test locking, and JSONL persistence are isolated in focused packages so behavior can be tested without a browser.
|
||||
|
||||
**Tech Stack:** Go standard library, static HTML/CSS/JavaScript browser fetch transfers, JSON Lines persistence, ipapi-compatible best-effort geolocation.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Project Skeleton And Probe Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `go.mod`
|
||||
- Create: `internal/probe/probe.go`
|
||||
- Create: `internal/probe/probe_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for target parsing and summary calculation**
|
||||
|
||||
Create `internal/probe/probe_test.go` with tests for `ParseTarget`, `Summarize`, packet loss, jitter, and stability score behavior.
|
||||
|
||||
- [ ] **Step 2: Run probe tests and verify they fail**
|
||||
|
||||
Run: `go test ./internal/probe`
|
||||
Expected: failure because `internal/probe` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement minimal probe types and calculations**
|
||||
|
||||
Create `go.mod` and `internal/probe/probe.go` with target parsing, sample types, real TCP/HTTP probe runner, summary types, and summary calculations.
|
||||
|
||||
- [ ] **Step 4: Run probe tests and verify they pass**
|
||||
|
||||
Run: `go test ./internal/probe`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: JSONL Record Store
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/store/store.go`
|
||||
- Create: `internal/store/store_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for append and list behavior**
|
||||
|
||||
Create store tests that append complete records to a temp JSONL file, reload them, verify newest-first ordering, preserve client metadata and sample curves, and ignore blank lines.
|
||||
|
||||
- [ ] **Step 2: Run store tests and verify they fail**
|
||||
|
||||
Run: `go test ./internal/store`
|
||||
Expected: failure because `internal/store` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement JSONL store**
|
||||
|
||||
Create `internal/store/store.go` with an append-only file store guarded by a mutex.
|
||||
|
||||
- [ ] **Step 4: Run store tests and verify they pass**
|
||||
|
||||
Run: `go test ./internal/store`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Web API And SSE
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/web/server.go`
|
||||
- Create: `internal/web/server_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing handler tests**
|
||||
|
||||
Create tests for `/api/records`, `/api/tests` validation, successful test creation, busy response when a test is active, lock release after completion, client metadata persistence, and SSE event streaming with an injected fake runner.
|
||||
|
||||
- [ ] **Step 2: Run web tests and verify they fail**
|
||||
|
||||
Run: `go test ./internal/web`
|
||||
Expected: failure because `internal/web` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement web server**
|
||||
|
||||
Create routes, JSON helpers, single-active-session state, client IP extraction, resolver injection, and SSE streaming. Inject the runner function so tests do not depend on real network timing.
|
||||
|
||||
- [ ] **Step 4: Run web tests and verify they pass**
|
||||
|
||||
Run: `go test ./internal/web`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Static Dashboard And Entrypoint
|
||||
|
||||
**Files:**
|
||||
- Create: `web/static/index.html`
|
||||
- Create: `web/static/styles.css`
|
||||
- Create: `web/static/app.js`
|
||||
- Create: `web/assets.go`
|
||||
- Create: `cmd/netstable/main.go`
|
||||
- Create: `README.md`
|
||||
- Create: `Makefile`
|
||||
- Create: `.gitignore`
|
||||
- Create: `deploy/netstable.service`
|
||||
|
||||
- [ ] **Step 1: Add browser dashboard**
|
||||
|
||||
Create a focused operational UI with target controls, live metrics, busy feedback, event log, live curve, historical time-series chart, and all user records.
|
||||
|
||||
- [ ] **Step 2: Add command-line entrypoint**
|
||||
|
||||
Create `cmd/netstable/main.go` with flags for listen address, data file, static asset directory, and IP metadata resolver settings.
|
||||
|
||||
- [ ] **Step 3: Add README deployment instructions**
|
||||
|
||||
Document local use, Linux build, upload, and systemd service setup.
|
||||
|
||||
- [ ] **Step 4: Run full verification**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS.
|
||||
|
||||
Run: `go run ./cmd/netstable -listen 127.0.0.1:18080 -data /tmp/netstable-records.jsonl`
|
||||
Expected: server starts and responds at `http://127.0.0.1:18080/`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Network Stability Tester Design
|
||||
|
||||
## Goal
|
||||
|
||||
Build a self-contained Go web service for testing upload, download, and latency between a browser user and the deployed server. A user opens the page, starts a live test, watches browser-measured speed and latency update in real time, sees the configured theoretical bandwidth limit, and reviews all historical test records from the same page. The same binary also provides a CLI client mode so other servers can run direct HTTP upload/download tests against the deployed node.
|
||||
|
||||
## Selected Approach
|
||||
|
||||
Use a single Go binary with no external runtime dependencies. The server exposes HTTP endpoints for browser download/upload traffic, serves an embedded static browser UI, embeds the ip2region IPv4 database for offline client region and ISP lookup, throttles generated test traffic when a startup Mbps limit is configured, and persists completed browser results as JSON Lines on disk.
|
||||
|
||||
This is preferred over PHP/LibreSpeed because it is easier to deploy on a weak network: upload one binary, start one service, and keep all behavior under our control.
|
||||
|
||||
## Scope
|
||||
|
||||
The first version enforces one active test at a time while still allowing other users to submit requests and receive a busy response. It measures:
|
||||
|
||||
- Browser download throughput from the deployed server.
|
||||
- Browser upload throughput to the deployed server.
|
||||
- Browser HTTP latency to the deployed server.
|
||||
- Failure rate approximation from failed browser transfer samples.
|
||||
- Jitter from the change between consecutive successful latency samples.
|
||||
- A stability score derived from success rate, latency, jitter, and failures.
|
||||
- Per-run event samples, summarized historical records, and saved user curves.
|
||||
- Anonymized client IP, best-effort region, city, country, ASN, and ISP/operator metadata.
|
||||
|
||||
## Architecture
|
||||
|
||||
The application is split into small Go packages:
|
||||
|
||||
- `internal/probe`: target parsing, sample types, summary types, and legacy probe helpers.
|
||||
- `internal/limit`: Mbps-to-byte-rate conversion and throttling delay calculations.
|
||||
- `internal/store`: append-only JSONL persistence and record loading for complete records.
|
||||
- `internal/web`: HTTP routes, global active-test lock, browser download/upload endpoints, completion persistence, static asset serving, API validation.
|
||||
- `internal/geo`: offline ip2region lookup first, then best-effort online IP metadata fallback through ipapi/ipinfo-compatible JSON.
|
||||
- `cmd/netstable`: command-line entrypoint for server startup and CLI client mode.
|
||||
- `internal/client`: CLI-compatible client runner that uses the same `/api/tests`, `/api/download`, `/api/upload`, and completion endpoints as the browser.
|
||||
- `web`: embedded static asset package.
|
||||
|
||||
The frontend lives in `web/static` and uses plain HTML, CSS, and JavaScript. It starts tests through `/api/tests`, continuously downloads bytes from `/api/download` for the configured phase duration, continuously uploads bytes to `/api/upload` for the same duration, submits results through `/api/tests/{id}/complete`, keeps an auxiliary SSE heartbeat open through `/api/health/events`, loads configuration from `/api/config`, loads records from `/api/records`, draws a live speed curve, and draws a historical time-series chart.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. The browser posts a test request with the current deployment host, per-phase duration, and timeout.
|
||||
2. The server checks the global active-test lock. If another test is active, it returns `409` with a wait message.
|
||||
3. The server captures client IP from `RemoteAddr` by default, or from proxy headers only when explicitly trusted, enriches it through the configured resolver, stores and returns an anonymized IP, creates a test session, and returns a session ID.
|
||||
4. The browser measures latency, continuously downloads generated bytes for the configured duration, then continuously uploads generated bytes for the same duration using that active session ID.
|
||||
5. The browser submits all samples to `/api/tests/{id}/complete`.
|
||||
6. The server writes a completed record containing client metadata, summary, and samples to the JSONL store.
|
||||
7. The server releases the active-test lock.
|
||||
8. The browser refreshes the record table and displays the latest summary.
|
||||
|
||||
The CLI client follows the same server API and lock behavior, but measures from the machine where the CLI is running instead of a browser tab.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Invalid targets, bad URLs, negative durations, and unsupported methods return JSON errors. Busy requests return `409` with a Chinese wait message. Browser transfer failures are saved as failed samples instead of crashing the page. IP metadata lookup failures fall back to the raw IP. Store write failures are reported through the completion endpoint and server logs.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests cover target parsing, summary calculation, IP resolver mapping, JSONL store behavior, active-test locking, download/upload endpoints, completion persistence, static file serving, and API validation. Handler tests use `httptest` with fake stores and runners. End-to-end verification starts the Go server locally and checks that the UI, API, browser transfer flow, busy response, and records respond.
|
||||
@@ -0,0 +1,5 @@
|
||||
module speedtest
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260530042244-d638015592ac
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260530042244-d638015592ac h1:ymMAqS6OhZCsRQmT85NJurvyS+BIoPpuJH7LiCXlzEs=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260530042244-d638015592ac/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8=
|
||||
@@ -0,0 +1,323 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"speedtest/internal/probe"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDownloadBytes = 4 * 1024 * 1024
|
||||
DefaultUploadBytes = 2 * 1024 * 1024
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
ServerURL string
|
||||
Target string
|
||||
Duration time.Duration
|
||||
Timeout time.Duration
|
||||
DownloadBytes int
|
||||
UploadBytes int
|
||||
OnSample func(probe.Sample)
|
||||
}
|
||||
|
||||
type createResponse struct {
|
||||
ID string `json:"id"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
CompleteURL string `json:"completeUrl"`
|
||||
}
|
||||
|
||||
type completeResponse struct {
|
||||
Summary probe.Summary `json:"summary"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, options Options) (probe.Summary, error) {
|
||||
options, server, err := normalizeOptions(options)
|
||||
if err != nil {
|
||||
return probe.Summary{}, err
|
||||
}
|
||||
httpClient := http.Client{Timeout: transferTimeout(options.Timeout)}
|
||||
|
||||
session, err := createSession(ctx, &httpClient, server, options)
|
||||
if err != nil {
|
||||
return probe.Summary{}, err
|
||||
}
|
||||
|
||||
var samples []probe.Sample
|
||||
record := func(sample probe.Sample) {
|
||||
samples = append(samples, sample)
|
||||
if options.OnSample != nil {
|
||||
options.OnSample(sample)
|
||||
}
|
||||
}
|
||||
|
||||
startedAt := time.Now().UTC()
|
||||
record(measureLatency(ctx, &httpClient, server))
|
||||
if err := runPhase(ctx, options.Duration, func() probe.Sample {
|
||||
return measureDownload(ctx, &httpClient, server, session.DownloadURL, options.DownloadBytes)
|
||||
}, record); err != nil {
|
||||
return probe.Summary{}, err
|
||||
}
|
||||
record(measureLatency(ctx, &httpClient, server))
|
||||
if err := runPhase(ctx, options.Duration, func() probe.Sample {
|
||||
return measureUpload(ctx, &httpClient, server, session.UploadURL, options.UploadBytes)
|
||||
}, record); err != nil {
|
||||
return probe.Summary{}, err
|
||||
}
|
||||
record(measureLatency(ctx, &httpClient, server))
|
||||
finishedAt := time.Now().UTC()
|
||||
|
||||
return completeSession(ctx, &httpClient, server, session.CompleteURL, startedAt, finishedAt, samples)
|
||||
}
|
||||
|
||||
func normalizeOptions(options Options) (Options, *url.URL, error) {
|
||||
if strings.TrimSpace(options.ServerURL) == "" {
|
||||
return Options{}, nil, errors.New("server URL is required")
|
||||
}
|
||||
server, err := url.Parse(strings.TrimRight(options.ServerURL, "/"))
|
||||
if err != nil || server.Scheme == "" || server.Host == "" || (server.Scheme != "http" && server.Scheme != "https") {
|
||||
return Options{}, nil, errors.New("server URL must be a valid http or https URL")
|
||||
}
|
||||
if strings.TrimSpace(options.Target) == "" {
|
||||
options.Target = server.Host
|
||||
}
|
||||
if options.Duration <= 0 {
|
||||
options.Duration = 30 * time.Second
|
||||
}
|
||||
if options.Timeout <= 0 {
|
||||
options.Timeout = 3 * time.Second
|
||||
}
|
||||
if options.DownloadBytes <= 0 {
|
||||
options.DownloadBytes = DefaultDownloadBytes
|
||||
}
|
||||
if options.UploadBytes <= 0 {
|
||||
options.UploadBytes = DefaultUploadBytes
|
||||
}
|
||||
return options, server, nil
|
||||
}
|
||||
|
||||
func createSession(ctx context.Context, httpClient *http.Client, server *url.URL, options Options) (createResponse, error) {
|
||||
payload := map[string]any{
|
||||
"target": options.Target,
|
||||
"httpUrl": resolve(server, "/api/config"),
|
||||
"durationSeconds": int(math.Max(1, math.Ceil(options.Duration.Seconds()))),
|
||||
"timeoutMillis": int(math.Max(100, float64(options.Timeout.Milliseconds()))),
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return createResponse{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, resolve(server, "/api/tests"), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return createResponse{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return createResponse{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
var session createResponse
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
if err := json.NewDecoder(response.Body).Decode(&session); err != nil {
|
||||
return createResponse{}, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
message := decodeError(response.Body)
|
||||
if message == "" {
|
||||
message = response.Status
|
||||
}
|
||||
return createResponse{}, fmt.Errorf("create test session: %s", message)
|
||||
}
|
||||
|
||||
func runPhase(ctx context.Context, duration time.Duration, measure func() probe.Sample, record func(probe.Sample)) error {
|
||||
deadline := time.Now().Add(duration)
|
||||
samples := 0
|
||||
for time.Now().Before(deadline) || samples == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
record(measure())
|
||||
samples++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func measureLatency(ctx context.Context, httpClient *http.Client, server *url.URL) probe.Sample {
|
||||
startedAt := time.Now()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, resolve(server, "/api/config?cache="+cacheToken()), nil)
|
||||
if err != nil {
|
||||
return failedSample("latency", startedAt, 0, 0, err)
|
||||
}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return failedSample("latency", startedAt, 0, 0, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
elapsed := time.Since(startedAt)
|
||||
return probe.Sample{
|
||||
At: time.Now().UTC(),
|
||||
Kind: "latency",
|
||||
Success: response.StatusCode >= 200 && response.StatusCode < 300,
|
||||
Latency: elapsed,
|
||||
LatencyMS: round2(float64(elapsed.Microseconds()) / 1000),
|
||||
Error: statusError(response),
|
||||
}
|
||||
}
|
||||
|
||||
func measureDownload(ctx context.Context, httpClient *http.Client, server *url.URL, path string, size int) probe.Sample {
|
||||
startedAt := time.Now()
|
||||
requestURL := resolve(server, path+"&bytes="+fmt.Sprint(size)+"&cache="+cacheToken())
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return failedSample("download", startedAt, 0, 0, err)
|
||||
}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return failedSample("download", startedAt, 0, 0, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
received, _ := io.Copy(io.Discard, response.Body)
|
||||
elapsed := time.Since(startedAt)
|
||||
return transferSample("download", response, startedAt, elapsed, received)
|
||||
}
|
||||
|
||||
func measureUpload(ctx context.Context, httpClient *http.Client, server *url.URL, path string, size int) probe.Sample {
|
||||
startedAt := time.Now()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, resolve(server, path+"&cache="+cacheToken()), bytes.NewReader(make([]byte, size)))
|
||||
if err != nil {
|
||||
return failedSample("upload", startedAt, 0, 0, err)
|
||||
}
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return failedSample("upload", startedAt, 0, 0, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
elapsed := time.Since(startedAt)
|
||||
return transferSample("upload", response, startedAt, elapsed, int64(size))
|
||||
}
|
||||
|
||||
func transferSample(kind string, response *http.Response, startedAt time.Time, elapsed time.Duration, bytes int64) probe.Sample {
|
||||
return probe.Sample{
|
||||
At: time.Now().UTC(),
|
||||
Kind: kind,
|
||||
Success: response.StatusCode >= 200 && response.StatusCode < 300,
|
||||
Latency: elapsed,
|
||||
LatencyMS: round2(float64(elapsed.Microseconds()) / 1000),
|
||||
Bytes: bytes,
|
||||
Mbps: mbps(bytes, elapsed),
|
||||
Error: statusError(response),
|
||||
}
|
||||
}
|
||||
|
||||
func completeSession(ctx context.Context, httpClient *http.Client, server *url.URL, path string, startedAt time.Time, finishedAt time.Time, samples []probe.Sample) (probe.Summary, error) {
|
||||
payload := map[string]any{
|
||||
"startedAt": startedAt,
|
||||
"finishedAt": finishedAt,
|
||||
"samples": samples,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return probe.Summary{}, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, resolve(server, path), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return probe.Summary{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := httpClient.Do(request)
|
||||
if err != nil {
|
||||
return probe.Summary{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
message := decodeError(response.Body)
|
||||
if message == "" {
|
||||
message = response.Status
|
||||
}
|
||||
return probe.Summary{}, fmt.Errorf("complete test session: %s", message)
|
||||
}
|
||||
var completed completeResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&completed); err != nil {
|
||||
return probe.Summary{}, err
|
||||
}
|
||||
return completed.Summary, nil
|
||||
}
|
||||
|
||||
func resolve(server *url.URL, path string) string {
|
||||
relative, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return server.String() + path
|
||||
}
|
||||
return server.ResolveReference(relative).String()
|
||||
}
|
||||
|
||||
func decodeError(reader io.Reader) string {
|
||||
var payload errorResponse
|
||||
if err := json.NewDecoder(reader).Decode(&payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return payload.Error
|
||||
}
|
||||
|
||||
func failedSample(kind string, startedAt time.Time, bytes int64, mbps float64, err error) probe.Sample {
|
||||
elapsed := time.Since(startedAt)
|
||||
return probe.Sample{
|
||||
At: time.Now().UTC(),
|
||||
Kind: kind,
|
||||
Success: false,
|
||||
Latency: elapsed,
|
||||
LatencyMS: round2(float64(elapsed.Microseconds()) / 1000),
|
||||
Bytes: bytes,
|
||||
Mbps: mbps,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
func statusError(response *http.Response) string {
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
return ""
|
||||
}
|
||||
return response.Status
|
||||
}
|
||||
|
||||
func mbps(bytes int64, elapsed time.Duration) float64 {
|
||||
if bytes <= 0 || elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return round2(float64(bytes) * 8 / float64(elapsed.Microseconds()))
|
||||
}
|
||||
|
||||
func round2(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func transferTimeout(timeout time.Duration) time.Duration {
|
||||
if timeout < 30*time.Second {
|
||||
return 30 * time.Second
|
||||
}
|
||||
return timeout
|
||||
}
|
||||
|
||||
func cacheToken() string {
|
||||
return fmt.Sprint(time.Now().UnixNano())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"net/http/httptest"
|
||||
|
||||
"speedtest/internal/probe"
|
||||
recordstore "speedtest/internal/store"
|
||||
appweb "speedtest/internal/web"
|
||||
)
|
||||
|
||||
func TestRunCompletesBrowserCompatibleSpeedTest(t *testing.T) {
|
||||
store := &memoryStore{}
|
||||
server := appweb.New(appweb.Options{Store: store, Runner: noopRunner})
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
summary, err := Run(context.Background(), Options{
|
||||
ServerURL: httpServer.URL,
|
||||
Duration: 20 * time.Millisecond,
|
||||
Timeout: time.Second,
|
||||
DownloadBytes: 1024,
|
||||
UploadBytes: 512,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if summary.Target == "" || summary.AvgDownloadMbps <= 0 || summary.AvgUploadMbps <= 0 {
|
||||
t.Fatalf("summary = %#v, want target and upload/download speeds", summary)
|
||||
}
|
||||
|
||||
records := store.snapshot()
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %d, want 1", len(records))
|
||||
}
|
||||
if records[0].Summary.ID != summary.ID {
|
||||
t.Fatalf("record summary id = %q, want %q", records[0].Summary.ID, summary.ID)
|
||||
}
|
||||
if !hasKind(records[0].Samples, "download") || !hasKind(records[0].Samples, "upload") {
|
||||
t.Fatalf("samples = %#v, want download and upload samples", records[0].Samples)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReturnsBusyError(t *testing.T) {
|
||||
server := appweb.New(appweb.Options{Store: &memoryStore{}, Runner: noopRunner})
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
_, err := Run(context.Background(), Options{
|
||||
ServerURL: httpServer.URL,
|
||||
Duration: 20 * time.Millisecond,
|
||||
DownloadBytes: 1024,
|
||||
UploadBytes: 512,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Run returned error: %v", err)
|
||||
}
|
||||
|
||||
createBusySession(t, httpServer.URL)
|
||||
_, err = Run(context.Background(), Options{
|
||||
ServerURL: httpServer.URL,
|
||||
Duration: 20 * time.Millisecond,
|
||||
DownloadBytes: 1024,
|
||||
UploadBytes: 512,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "正在测速") {
|
||||
t.Fatalf("busy error = %v, want Chinese busy message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createBusySession(t *testing.T, serverURL string) {
|
||||
t.Helper()
|
||||
server, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server URL: %v", err)
|
||||
}
|
||||
httpClient := http.Client{Timeout: time.Second}
|
||||
_, err = createSession(context.Background(), &httpClient, server, Options{
|
||||
Target: server.Host,
|
||||
Duration: time.Second,
|
||||
Timeout: time.Second,
|
||||
DownloadBytes: 1024,
|
||||
UploadBytes: 512,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create busy session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func hasKind(samples []probe.Sample, kind string) bool {
|
||||
for _, sample := range samples {
|
||||
if sample.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
records []recordstore.Record
|
||||
}
|
||||
|
||||
func (s *memoryStore) Append(ctx context.Context, record recordstore.Record) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.records = append([]recordstore.Record{record}, s.records...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) List(ctx context.Context, limit int) ([]recordstore.Record, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if limit < 1 || limit > len(s.records) {
|
||||
limit = len(s.records)
|
||||
}
|
||||
out := make([]recordstore.Record, limit)
|
||||
copy(out, s.records[:limit])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) snapshot() []recordstore.Record {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]recordstore.Record, len(s.records))
|
||||
copy(out, s.records)
|
||||
return out
|
||||
}
|
||||
|
||||
func noopRunner(ctx context.Context, request appweb.TestRequest, emit func(appweb.Event) error) (probe.Summary, error) {
|
||||
return probe.Summary{Target: request.Target.Address, StartedAt: time.Now(), FinishedAt: time.Now()}, nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package geo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
recordstore "speedtest/internal/store"
|
||||
)
|
||||
|
||||
const defaultBaseURL = "https://ipapi.co"
|
||||
|
||||
type Resolver struct {
|
||||
baseURL string
|
||||
client http.Client
|
||||
}
|
||||
|
||||
type ipapiResponse struct {
|
||||
IP string `json:"ip"`
|
||||
Query string `json:"query"`
|
||||
CountryName string `json:"country_name"`
|
||||
Country string `json:"country"`
|
||||
Region string `json:"region"`
|
||||
RegionName string `json:"region_name"`
|
||||
City string `json:"city"`
|
||||
ASN string `json:"asn"`
|
||||
AS string `json:"as"`
|
||||
Org string `json:"org"`
|
||||
ISP string `json:"isp"`
|
||||
Organization string `json:"organization"`
|
||||
Error bool `json:"error"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func New(baseURL string, timeout time.Duration) *Resolver {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
return &Resolver{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
client: http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func Chain(resolvers ...*Resolver) func(context.Context, string) (recordstore.ClientInfo, error) {
|
||||
return func(ctx context.Context, ip string) (recordstore.ClientInfo, error) {
|
||||
var firstErr error
|
||||
var best recordstore.ClientInfo
|
||||
for _, resolver := range resolvers {
|
||||
if resolver == nil {
|
||||
continue
|
||||
}
|
||||
info, err := resolver.Resolve(ctx, ip)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(info.ISP) != "" {
|
||||
return info, nil
|
||||
}
|
||||
if best.IP == "" {
|
||||
best = info
|
||||
}
|
||||
}
|
||||
if best.IP != "" {
|
||||
return best, nil
|
||||
}
|
||||
if firstErr != nil {
|
||||
return recordstore.ClientInfo{}, firstErr
|
||||
}
|
||||
return recordstore.ClientInfo{}, fmt.Errorf("no IP resolvers configured")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) Resolve(ctx context.Context, ip string) (recordstore.ClientInfo, error) {
|
||||
requestURL := r.baseURL + "/" + url.PathEscape(ip) + "/json/"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return recordstore.ClientInfo{}, err
|
||||
}
|
||||
|
||||
httpResponse, err := r.client.Do(request)
|
||||
if err != nil {
|
||||
return recordstore.ClientInfo{}, err
|
||||
}
|
||||
defer httpResponse.Body.Close()
|
||||
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
|
||||
return recordstore.ClientInfo{}, fmt.Errorf("ip lookup returned %s", httpResponse.Status)
|
||||
}
|
||||
|
||||
var payload ipapiResponse
|
||||
if err := json.NewDecoder(httpResponse.Body).Decode(&payload); err != nil {
|
||||
return recordstore.ClientInfo{}, err
|
||||
}
|
||||
if payload.Error {
|
||||
if payload.Reason == "" {
|
||||
payload.Reason = "provider returned an error"
|
||||
}
|
||||
return recordstore.ClientInfo{}, fmt.Errorf("ip lookup failed: %s", payload.Reason)
|
||||
}
|
||||
|
||||
info := recordstore.ClientInfo{
|
||||
IP: firstNonEmpty(payload.IP, payload.Query),
|
||||
ISP: firstNonEmpty(payload.ISP, payload.Org, payload.Organization),
|
||||
Region: firstNonEmpty(payload.Region, payload.RegionName),
|
||||
Country: firstNonEmpty(payload.CountryName, payload.Country),
|
||||
City: payload.City,
|
||||
ASN: firstNonEmpty(payload.ASN, payload.AS),
|
||||
Source: "ipapi.co",
|
||||
}
|
||||
if info.IP == "" {
|
||||
info.IP = ip
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package geo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestResolverMapsIPAPIResponseToClientInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/203.0.113.20/json/" {
|
||||
t.Fatalf("path = %q, want /203.0.113.20/json/", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"ip":"203.0.113.20",
|
||||
"country_name":"China",
|
||||
"region":"Beijing",
|
||||
"city":"Beijing",
|
||||
"asn":"AS64512",
|
||||
"org":"Example Mobile"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resolver := New(server.URL, time.Second)
|
||||
info, err := resolver.Resolve(context.Background(), "203.0.113.20")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
|
||||
if info.IP != "203.0.113.20" || info.Country != "China" || info.Region != "Beijing" || info.City != "Beijing" {
|
||||
t.Fatalf("location info = %#v, want mapped country/region/city", info)
|
||||
}
|
||||
if info.ISP != "Example Mobile" || info.ASN != "AS64512" || info.Source != "ipapi.co" {
|
||||
t.Fatalf("network info = %#v, want mapped org/asn/source", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverMapsAlternateProviderNetworkFields(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"query":"203.0.113.21",
|
||||
"country":"China",
|
||||
"region_name":"Shanghai",
|
||||
"city":"Shanghai",
|
||||
"as":"AS64513",
|
||||
"isp":"Example Unicom"
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resolver := New(server.URL, time.Second)
|
||||
info, err := resolver.Resolve(context.Background(), "203.0.113.21")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
|
||||
if info.IP != "203.0.113.21" || info.Country != "China" || info.Region != "Shanghai" {
|
||||
t.Fatalf("location info = %#v, want alternate provider fields", info)
|
||||
}
|
||||
if info.ISP != "Example Unicom" || info.ASN != "AS64513" {
|
||||
t.Fatalf("network info = %#v, want alternate isp/as fields", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChainFallsBackWhenPrimaryResolverFails(t *testing.T) {
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":true,"reason":"rate limited"}`))
|
||||
}))
|
||||
defer primary.Close()
|
||||
fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"ip":"39.181.21.1",
|
||||
"region":"Shanghai",
|
||||
"city":"Shanghai",
|
||||
"org":"AS56041 China Mobile communications corporation"
|
||||
}`))
|
||||
}))
|
||||
defer fallback.Close()
|
||||
|
||||
resolve := Chain(New(primary.URL, time.Second), New(fallback.URL, time.Second))
|
||||
info, err := resolve(context.Background(), "39.181.21.1")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve returned error: %v", err)
|
||||
}
|
||||
if info.ISP != "AS56041 China Mobile communications corporation" || info.Region != "Shanghai" {
|
||||
t.Fatalf("info = %#v, want fallback network metadata", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverReturnsErrorForProviderFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":true,"reason":"rate limited"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resolver := New(server.URL, time.Second)
|
||||
if _, err := resolver.Resolve(context.Background(), "203.0.113.20"); err == nil {
|
||||
t.Fatal("Resolve succeeded, want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package geo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
|
||||
|
||||
recordstore "speedtest/internal/store"
|
||||
)
|
||||
|
||||
type XDBResolver struct {
|
||||
mu sync.Mutex
|
||||
searcher *xdb.Searcher
|
||||
}
|
||||
|
||||
func NewXDBResolver(db []byte) (*XDBResolver, error) {
|
||||
if len(db) == 0 {
|
||||
return nil, fmt.Errorf("empty ip2region xdb")
|
||||
}
|
||||
searcher, err := xdb.NewWithBuffer(xdb.IPv4, db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create ip2region searcher: %w", err)
|
||||
}
|
||||
return &XDBResolver{searcher: searcher}, nil
|
||||
}
|
||||
|
||||
func (r *XDBResolver) Resolve(ctx context.Context, ip string) (recordstore.ClientInfo, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return recordstore.ClientInfo{}, err
|
||||
}
|
||||
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||
if parsed == nil || parsed.To4() == nil {
|
||||
return recordstore.ClientInfo{}, fmt.Errorf("ip2region supports IPv4 only in embedded database")
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
region, err := r.searcher.Search(parsed.String())
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
return recordstore.ClientInfo{}, err
|
||||
}
|
||||
if strings.TrimSpace(region) == "" {
|
||||
return recordstore.ClientInfo{}, fmt.Errorf("ip2region returned empty region")
|
||||
}
|
||||
info := clientInfoFromIP2Region(ip, region)
|
||||
info.Source = "ip2region"
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func clientInfoFromIP2Region(ip string, region string) recordstore.ClientInfo {
|
||||
parts := strings.Split(region, "|")
|
||||
country := field(parts, 0)
|
||||
regionName := field(parts, 1)
|
||||
city := field(parts, 2)
|
||||
isp := field(parts, 3)
|
||||
return recordstore.ClientInfo{
|
||||
IP: ip,
|
||||
ISP: isp,
|
||||
Region: regionName,
|
||||
Country: country,
|
||||
City: city,
|
||||
}
|
||||
}
|
||||
|
||||
func field(parts []string, index int) string {
|
||||
if index >= len(parts) {
|
||||
return ""
|
||||
}
|
||||
value := strings.TrimSpace(parts[index])
|
||||
if value == "0" || value == "未知" || strings.EqualFold(value, "unknown") {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package geo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
ipdata "speedtest/data"
|
||||
)
|
||||
|
||||
func TestClientInfoFromIP2RegionParsesRegionFields(t *testing.T) {
|
||||
info := clientInfoFromIP2Region("39.181.21.1", "中国|浙江省|杭州市|移动|CN")
|
||||
|
||||
if info.Country != "中国" || info.Region != "浙江省" || info.City != "杭州市" {
|
||||
t.Fatalf("location info = %#v, want parsed country/region/city", info)
|
||||
}
|
||||
if info.ISP != "移动" {
|
||||
t.Fatalf("ISP = %q, want 移动", info.ISP)
|
||||
}
|
||||
if info.ASN != "" {
|
||||
t.Fatalf("ASN = %q, want empty because ip2region field 5 is country code", info.ASN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedXDBResolverReturnsISP(t *testing.T) {
|
||||
db, err := ipdata.FileSystem().ReadFile("ip2region_v4.xdb")
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded xdb: %v", err)
|
||||
}
|
||||
resolver, err := NewXDBResolver(db)
|
||||
if err != nil {
|
||||
t.Fatalf("NewXDBResolver: %v", err)
|
||||
}
|
||||
|
||||
info, err := resolver.Resolve(context.Background(), "39.181.21.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve returned error: %v", err)
|
||||
}
|
||||
if info.IP != "39.181.21.1" || info.ISP == "" {
|
||||
t.Fatalf("info = %#v, want ip and ISP from embedded xdb", info)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package limit
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Limiter struct {
|
||||
mbps float64
|
||||
bytesPerSecond int64
|
||||
}
|
||||
|
||||
func New(mbps float64) Limiter {
|
||||
if mbps <= 0 {
|
||||
return Limiter{}
|
||||
}
|
||||
return Limiter{
|
||||
mbps: mbps,
|
||||
bytesPerSecond: int64(math.Round(mbps * 1_000_000 / 8)),
|
||||
}
|
||||
}
|
||||
|
||||
func (l Limiter) Enabled() bool {
|
||||
return l.bytesPerSecond > 0
|
||||
}
|
||||
|
||||
func (l Limiter) Mbps() float64 {
|
||||
return l.mbps
|
||||
}
|
||||
|
||||
func (l Limiter) BytesPerSecond() int64 {
|
||||
return l.bytesPerSecond
|
||||
}
|
||||
|
||||
func (l Limiter) DelayFor(bytesSent int64, elapsed time.Duration) time.Duration {
|
||||
if !l.Enabled() || bytesSent <= 0 {
|
||||
return 0
|
||||
}
|
||||
expected := time.Duration(float64(bytesSent) / float64(l.bytesPerSecond) * float64(time.Second))
|
||||
if expected <= elapsed {
|
||||
return 0
|
||||
}
|
||||
return expected - elapsed
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package limit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLimiterConvertsMbpsToBytesPerSecond(t *testing.T) {
|
||||
limiter := New(80)
|
||||
|
||||
if !limiter.Enabled() {
|
||||
t.Fatal("limiter is disabled, want enabled")
|
||||
}
|
||||
if limiter.Mbps() != 80 {
|
||||
t.Fatalf("Mbps = %.2f, want 80", limiter.Mbps())
|
||||
}
|
||||
if limiter.BytesPerSecond() != 10_000_000 {
|
||||
t.Fatalf("BytesPerSecond = %d, want 10000000", limiter.BytesPerSecond())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterCalculatesDelay(t *testing.T) {
|
||||
limiter := New(80)
|
||||
|
||||
delay := limiter.DelayFor(10_000_000, 500*time.Millisecond)
|
||||
if delay != 500*time.Millisecond {
|
||||
t.Fatalf("DelayFor = %s, want 500ms", delay)
|
||||
}
|
||||
|
||||
noDelay := limiter.DelayFor(10_000_000, time.Second)
|
||||
if noDelay != 0 {
|
||||
t.Fatalf("DelayFor at expected elapsed = %s, want 0", noDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlimitedLimiterDoesNotDelay(t *testing.T) {
|
||||
limiter := New(0)
|
||||
|
||||
if limiter.Enabled() {
|
||||
t.Fatal("limiter is enabled, want disabled")
|
||||
}
|
||||
if delay := limiter.DelayFor(10_000_000, 0); delay != 0 {
|
||||
t.Fatalf("DelayFor unlimited = %s, want 0", delay)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Target struct {
|
||||
Raw string `json:"raw"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
type Sample struct {
|
||||
At time.Time `json:"at"`
|
||||
Kind string `json:"kind"`
|
||||
Success bool `json:"success"`
|
||||
Latency time.Duration `json:"-"`
|
||||
LatencyMS float64 `json:"latencyMs"`
|
||||
Bytes int64 `json:"bytes,omitempty"`
|
||||
Mbps float64 `json:"mbps,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Target string `json:"target"`
|
||||
HTTPURL string `json:"httpUrl,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
Samples int `json:"samples"`
|
||||
Successes int `json:"successes"`
|
||||
Failures int `json:"failures"`
|
||||
PacketLossPct float64 `json:"packetLossPct"`
|
||||
AvgLatencyMS float64 `json:"avgLatencyMs"`
|
||||
MinLatencyMS float64 `json:"minLatencyMs"`
|
||||
MaxLatencyMS float64 `json:"maxLatencyMs"`
|
||||
JitterMS float64 `json:"jitterMs"`
|
||||
AvgDownloadMbps float64 `json:"avgDownloadMbps,omitempty"`
|
||||
MaxDownloadMbps float64 `json:"maxDownloadMbps,omitempty"`
|
||||
AvgUploadMbps float64 `json:"avgUploadMbps,omitempty"`
|
||||
MaxUploadMbps float64 `json:"maxUploadMbps,omitempty"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Target Target
|
||||
HTTPURL string
|
||||
Duration time.Duration
|
||||
Interval time.Duration
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func ParseTarget(input string) (Target, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return Target{}, errors.New("target is required")
|
||||
}
|
||||
|
||||
host, port, err := parseHostPort(raw)
|
||||
if err != nil {
|
||||
return Target{}, err
|
||||
}
|
||||
if host == "" {
|
||||
return Target{}, errors.New("target host is required")
|
||||
}
|
||||
|
||||
return Target{
|
||||
Raw: raw,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Address: net.JoinHostPort(host, strconv.Itoa(port)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, config Config, emit func(Sample) error) (Summary, error) {
|
||||
config = normalizeConfig(config)
|
||||
startedAt := time.Now().UTC()
|
||||
deadline := startedAt.Add(config.Duration)
|
||||
var samples []Sample
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Summarize(config.Target.Address, startedAt, time.Now().UTC(), samples), err
|
||||
}
|
||||
|
||||
tcpSample := probeTCP(ctx, config.Target, config.Timeout)
|
||||
samples = append(samples, tcpSample)
|
||||
if emit != nil {
|
||||
if err := emit(tcpSample); err != nil {
|
||||
return Summarize(config.Target.Address, startedAt, time.Now().UTC(), samples), err
|
||||
}
|
||||
}
|
||||
|
||||
if config.HTTPURL != "" {
|
||||
httpSample := probeHTTP(ctx, config.HTTPURL, config.Timeout)
|
||||
samples = append(samples, httpSample)
|
||||
if emit != nil {
|
||||
if err := emit(httpSample); err != nil {
|
||||
return Summarize(config.Target.Address, startedAt, time.Now().UTC(), samples), err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if time.Now().UTC().After(deadline) {
|
||||
break
|
||||
}
|
||||
|
||||
wait := config.Interval
|
||||
remaining := time.Until(deadline)
|
||||
if remaining < wait {
|
||||
wait = remaining
|
||||
}
|
||||
if wait <= 0 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Summarize(config.Target.Address, startedAt, time.Now().UTC(), samples), ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
|
||||
finishedAt := time.Now().UTC()
|
||||
summary := Summarize(config.Target.Address, startedAt, finishedAt, samples)
|
||||
summary.HTTPURL = config.HTTPURL
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func Summarize(target string, startedAt, finishedAt time.Time, samples []Sample) Summary {
|
||||
summary := Summary{
|
||||
Target: target,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: finishedAt,
|
||||
DurationMS: finishedAt.Sub(startedAt).Milliseconds(),
|
||||
Samples: len(samples),
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
return summary
|
||||
}
|
||||
|
||||
var totalLatency time.Duration
|
||||
var minLatency time.Duration
|
||||
var maxLatency time.Duration
|
||||
var lastLatency time.Duration
|
||||
var jitterTotal time.Duration
|
||||
var jitterPairs int
|
||||
|
||||
for _, sample := range samples {
|
||||
if !sample.Success {
|
||||
summary.Failures++
|
||||
continue
|
||||
}
|
||||
|
||||
summary.Successes++
|
||||
totalLatency += sample.Latency
|
||||
|
||||
if summary.Successes == 1 || sample.Latency < minLatency {
|
||||
minLatency = sample.Latency
|
||||
}
|
||||
if sample.Latency > maxLatency {
|
||||
maxLatency = sample.Latency
|
||||
}
|
||||
if summary.Successes > 1 {
|
||||
jitterTotal += absDuration(sample.Latency - lastLatency)
|
||||
jitterPairs++
|
||||
}
|
||||
lastLatency = sample.Latency
|
||||
}
|
||||
|
||||
if summary.Samples > 0 {
|
||||
summary.PacketLossPct = round2(float64(summary.Failures) / float64(summary.Samples) * 100)
|
||||
}
|
||||
if summary.Successes > 0 {
|
||||
summary.AvgLatencyMS = round2(float64(totalLatency.Microseconds()) / 1000 / float64(summary.Successes))
|
||||
summary.MinLatencyMS = round2(float64(minLatency.Microseconds()) / 1000)
|
||||
summary.MaxLatencyMS = round2(float64(maxLatency.Microseconds()) / 1000)
|
||||
}
|
||||
if jitterPairs > 0 {
|
||||
summary.JitterMS = round2(float64(jitterTotal.Microseconds()) / 1000 / float64(jitterPairs))
|
||||
}
|
||||
summary.Score = stabilityScore(summary)
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
func normalizeConfig(config Config) Config {
|
||||
if config.Duration <= 0 {
|
||||
config.Duration = 30 * time.Second
|
||||
}
|
||||
if config.Interval <= 0 {
|
||||
config.Interval = time.Second
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 3 * time.Second
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func probeTCP(ctx context.Context, target Target, timeout time.Duration) Sample {
|
||||
start := time.Now()
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", target.Address)
|
||||
latency := time.Since(start)
|
||||
sample := newSample("tcp", latency)
|
||||
if err != nil {
|
||||
sample.Success = false
|
||||
sample.Error = err.Error()
|
||||
return sample
|
||||
}
|
||||
_ = conn.Close()
|
||||
sample.Success = true
|
||||
return sample
|
||||
}
|
||||
|
||||
func probeHTTP(ctx context.Context, httpURL string, timeout time.Duration) Sample {
|
||||
start := time.Now()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, httpURL, nil)
|
||||
if err != nil {
|
||||
sample := newSample("http", 0)
|
||||
sample.Error = err.Error()
|
||||
return sample
|
||||
}
|
||||
|
||||
client := http.Client{Timeout: timeout}
|
||||
response, err := client.Do(request)
|
||||
latency := time.Since(start)
|
||||
sample := newSample("http", latency)
|
||||
if err != nil {
|
||||
sample.Error = err.Error()
|
||||
return sample
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1024))
|
||||
if response.StatusCode >= 500 {
|
||||
sample.Error = response.Status
|
||||
return sample
|
||||
}
|
||||
sample.Success = true
|
||||
return sample
|
||||
}
|
||||
|
||||
func newSample(kind string, latency time.Duration) Sample {
|
||||
return Sample{
|
||||
At: time.Now().UTC(),
|
||||
Kind: kind,
|
||||
Latency: latency,
|
||||
LatencyMS: round2(float64(latency.Microseconds()) / 1000),
|
||||
}
|
||||
}
|
||||
|
||||
func parseHostPort(raw string) (string, int, error) {
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("invalid URL target: %w", err)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
portText := parsed.Port()
|
||||
if portText == "" {
|
||||
switch parsed.Scheme {
|
||||
case "https":
|
||||
portText = "443"
|
||||
case "http":
|
||||
portText = "80"
|
||||
default:
|
||||
return "", 0, fmt.Errorf("unsupported URL scheme %q", parsed.Scheme)
|
||||
}
|
||||
}
|
||||
port, err := parsePort(portText)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
if host, portText, err := net.SplitHostPort(raw); err == nil {
|
||||
port, err := parsePort(portText)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return trimIPv6Brackets(host), port, nil
|
||||
}
|
||||
|
||||
if strings.Count(raw, ":") == 1 {
|
||||
host, portText, _ := strings.Cut(raw, ":")
|
||||
port, err := parsePort(portText)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return host, port, nil
|
||||
}
|
||||
|
||||
return trimIPv6Brackets(raw), 80, nil
|
||||
}
|
||||
|
||||
func parsePort(portText string) (int, error) {
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid target port %q", portText)
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return 0, fmt.Errorf("target port %d is out of range", port)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
func trimIPv6Brackets(host string) string {
|
||||
return strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
}
|
||||
|
||||
func stabilityScore(summary Summary) float64 {
|
||||
if summary.Samples == 0 {
|
||||
return 0
|
||||
}
|
||||
score := 100.0
|
||||
score -= summary.PacketLossPct * 1.4
|
||||
score -= math.Min(summary.AvgLatencyMS/20, 20)
|
||||
score -= math.Min(summary.JitterMS/5, 15)
|
||||
if summary.Failures > 0 {
|
||||
score -= math.Min(float64(summary.Failures)*2, 10)
|
||||
}
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
return round2(score)
|
||||
}
|
||||
|
||||
func absDuration(value time.Duration) time.Duration {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func round2(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseTargetDefaultsPlainHostToPort80(t *testing.T) {
|
||||
target, err := ParseTarget(" example.com ")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTarget returned error: %v", err)
|
||||
}
|
||||
|
||||
if target.Host != "example.com" {
|
||||
t.Fatalf("Host = %q, want example.com", target.Host)
|
||||
}
|
||||
if target.Port != 80 {
|
||||
t.Fatalf("Port = %d, want 80", target.Port)
|
||||
}
|
||||
if target.Address != "example.com:80" {
|
||||
t.Fatalf("Address = %q, want example.com:80", target.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetUsesExplicitPort(t *testing.T) {
|
||||
target, err := ParseTarget("103.46.93.38:443")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTarget returned error: %v", err)
|
||||
}
|
||||
|
||||
if target.Host != "103.46.93.38" || target.Port != 443 || target.Address != "103.46.93.38:443" {
|
||||
t.Fatalf("target = %#v, want host 103.46.93.38 port 443 address 103.46.93.38:443", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetDefaultsHTTPSURLToPort443(t *testing.T) {
|
||||
target, err := ParseTarget("https://example.com/health")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTarget returned error: %v", err)
|
||||
}
|
||||
|
||||
if target.Host != "example.com" || target.Port != 443 || target.Address != "example.com:443" {
|
||||
t.Fatalf("target = %#v, want host example.com port 443 address example.com:443", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetRejectsBlankAndBadPort(t *testing.T) {
|
||||
for _, input := range []string{"", " ", "example.com:abc", "example.com:70000"} {
|
||||
if _, err := ParseTarget(input); err == nil {
|
||||
t.Fatalf("ParseTarget(%q) succeeded, want error", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeCalculatesLatencyLossJitterAndScore(t *testing.T) {
|
||||
start := time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC)
|
||||
samples := []Sample{
|
||||
{At: start, Kind: "tcp", Success: true, Latency: 40 * time.Millisecond},
|
||||
{At: start.Add(time.Second), Kind: "tcp", Success: true, Latency: 55 * time.Millisecond},
|
||||
{At: start.Add(2 * time.Second), Kind: "tcp", Success: false, Error: "timeout"},
|
||||
{At: start.Add(3 * time.Second), Kind: "http", Success: true, Latency: 70 * time.Millisecond},
|
||||
}
|
||||
|
||||
summary := Summarize("103.46.93.38:80", start, start.Add(4*time.Second), samples)
|
||||
|
||||
if summary.Target != "103.46.93.38:80" {
|
||||
t.Fatalf("Target = %q, want 103.46.93.38:80", summary.Target)
|
||||
}
|
||||
if summary.Samples != 4 || summary.Successes != 3 || summary.Failures != 1 {
|
||||
t.Fatalf("counts = samples %d successes %d failures %d, want 4/3/1", summary.Samples, summary.Successes, summary.Failures)
|
||||
}
|
||||
if summary.AvgLatencyMS != 55 {
|
||||
t.Fatalf("AvgLatencyMS = %.2f, want 55", summary.AvgLatencyMS)
|
||||
}
|
||||
if summary.MinLatencyMS != 40 || summary.MaxLatencyMS != 70 {
|
||||
t.Fatalf("latency range = %.2f..%.2f, want 40..70", summary.MinLatencyMS, summary.MaxLatencyMS)
|
||||
}
|
||||
if !nearlyEqual(summary.PacketLossPct, 25, 0.001) {
|
||||
t.Fatalf("PacketLossPct = %.4f, want 25", summary.PacketLossPct)
|
||||
}
|
||||
if !nearlyEqual(summary.JitterMS, 15, 0.001) {
|
||||
t.Fatalf("JitterMS = %.4f, want 15", summary.JitterMS)
|
||||
}
|
||||
if summary.Score <= 0 || summary.Score >= 100 {
|
||||
t.Fatalf("Score = %.2f, want positive degraded score below 100", summary.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeHandlesNoSamples(t *testing.T) {
|
||||
start := time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC)
|
||||
summary := Summarize("example.com:80", start, start, nil)
|
||||
|
||||
if summary.Samples != 0 || summary.Successes != 0 || summary.Failures != 0 {
|
||||
t.Fatalf("counts = samples %d successes %d failures %d, want all zero", summary.Samples, summary.Successes, summary.Failures)
|
||||
}
|
||||
if summary.PacketLossPct != 0 || summary.Score != 0 {
|
||||
t.Fatalf("empty summary loss %.2f score %.2f, want 0/0", summary.PacketLossPct, summary.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEmitsSuccessfulTCPSamplesAgainstLocalServer(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
target, err := ParseTarget(listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTarget: %v", err)
|
||||
}
|
||||
var emitted []Sample
|
||||
summary, err := Run(context.Background(), Config{
|
||||
Target: target,
|
||||
Duration: 80 * time.Millisecond,
|
||||
Interval: 20 * time.Millisecond,
|
||||
Timeout: 100 * time.Millisecond,
|
||||
}, func(sample Sample) error {
|
||||
emitted = append(emitted, sample)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(emitted) == 0 {
|
||||
t.Fatal("no samples emitted")
|
||||
}
|
||||
if summary.Successes == 0 || summary.Failures != 0 {
|
||||
t.Fatalf("summary successes/failures = %d/%d, want successes and no failures", summary.Successes, summary.Failures)
|
||||
}
|
||||
if emitted[0].Kind != "tcp" || !emitted[0].Success || emitted[0].LatencyMS <= 0 {
|
||||
t.Fatalf("first sample = %#v, want successful tcp sample with latency", emitted[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEmitsHTTPSamplesWhenHTTPURLConfigured(t *testing.T) {
|
||||
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer httpServer.Close()
|
||||
|
||||
target, err := ParseTarget(httpServer.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTarget: %v", err)
|
||||
}
|
||||
var sawHTTP bool
|
||||
summary, err := Run(context.Background(), Config{
|
||||
Target: target,
|
||||
HTTPURL: httpServer.URL,
|
||||
Duration: 60 * time.Millisecond,
|
||||
Interval: 20 * time.Millisecond,
|
||||
Timeout: 100 * time.Millisecond,
|
||||
}, func(sample Sample) error {
|
||||
if sample.Kind == "http" && sample.Success && sample.LatencyMS > 0 {
|
||||
sawHTTP = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if !sawHTTP {
|
||||
t.Fatal("did not emit a successful http sample")
|
||||
}
|
||||
if summary.Samples == 0 || summary.Successes == 0 {
|
||||
t.Fatalf("summary = %#v, want successful samples", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func nearlyEqual(got, want, tolerance float64) bool {
|
||||
return math.Abs(got-want) <= tolerance
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"speedtest/internal/probe"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
IP string `json:"ip"`
|
||||
ISP string `json:"isp,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
ASN string `json:"asn,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
Client ClientInfo `json:"client"`
|
||||
Summary probe.Summary `json:"summary"`
|
||||
Samples []probe.Sample `json:"samples"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
func New(path string) *Store {
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
func (s *Store) Append(ctx context.Context, record Record) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return fmt.Errorf("create record directory: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open record file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
if err := encoder.Encode(record); err != nil {
|
||||
return fmt.Errorf("write record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
file, err := os.Open(s.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Record{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("open record file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var records []Record
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var record Record
|
||||
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||
return nil, fmt.Errorf("parse record line: %w", err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read record file: %w", err)
|
||||
}
|
||||
|
||||
reverse(records)
|
||||
if len(records) > limit {
|
||||
records = records[:limit]
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func reverse(records []Record) {
|
||||
for left, right := 0, len(records)-1; left < right; left, right = left+1, right-1 {
|
||||
records[left], records[right] = records[right], records[left]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"speedtest/internal/probe"
|
||||
)
|
||||
|
||||
func TestStoreAppendsAndListsRecordsNewestFirst(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "records.jsonl")
|
||||
store := New(path)
|
||||
ctx := context.Background()
|
||||
|
||||
first := Record{
|
||||
ID: "run-1",
|
||||
Client: ClientInfo{IP: "198.51.100.10", ISP: "Example Telecom", Region: "Shanghai"},
|
||||
CreatedAt: time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC),
|
||||
Samples: []probe.Sample{{At: time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC), Kind: "tcp", Success: true, LatencyMS: 30}},
|
||||
Summary: probe.Summary{
|
||||
ID: "run-1",
|
||||
Target: "example.com:80",
|
||||
StartedAt: time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC),
|
||||
FinishedAt: time.Date(2026, 6, 5, 8, 0, 5, 0, time.UTC),
|
||||
Samples: 1,
|
||||
Score: 95,
|
||||
},
|
||||
}
|
||||
second := Record{
|
||||
ID: "run-2",
|
||||
Client: ClientInfo{IP: "203.0.113.20", ISP: "Example Mobile", Region: "Beijing"},
|
||||
CreatedAt: time.Date(2026, 6, 5, 8, 1, 0, 0, time.UTC),
|
||||
Samples: []probe.Sample{{At: time.Date(2026, 6, 5, 8, 1, 0, 0, time.UTC), Kind: "http", Success: true, LatencyMS: 45}},
|
||||
Summary: probe.Summary{
|
||||
ID: "run-2",
|
||||
Target: "103.46.93.38:443",
|
||||
StartedAt: time.Date(2026, 6, 5, 8, 1, 0, 0, time.UTC),
|
||||
FinishedAt: time.Date(2026, 6, 5, 8, 1, 5, 0, time.UTC),
|
||||
Samples: 1,
|
||||
Score: 88,
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.Append(ctx, first); err != nil {
|
||||
t.Fatalf("Append first: %v", err)
|
||||
}
|
||||
if err := store.Append(ctx, second); err != nil {
|
||||
t.Fatalf("Append second: %v", err)
|
||||
}
|
||||
|
||||
records, err := store.List(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("len(records) = %d, want 2", len(records))
|
||||
}
|
||||
if records[0].ID != "run-2" || records[1].ID != "run-1" {
|
||||
t.Fatalf("records order = %s, %s; want run-2, run-1", records[0].ID, records[1].ID)
|
||||
}
|
||||
if records[0].Client.IP != "203.0.113.20" || records[0].Client.ISP != "Example Mobile" {
|
||||
t.Fatalf("client info = %#v, want persisted IP and ISP", records[0].Client)
|
||||
}
|
||||
if len(records[0].Samples) != 1 || records[0].Samples[0].LatencyMS != 45 {
|
||||
t.Fatalf("samples = %#v, want persisted curve samples", records[0].Samples)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreListHonorsLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "records.jsonl")
|
||||
store := New(path)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, id := range []string{"run-1", "run-2", "run-3"} {
|
||||
if err := store.Append(ctx, Record{ID: id, Summary: probe.Summary{ID: id, Target: "example.com:80"}}); err != nil {
|
||||
t.Fatalf("Append %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
records, err := store.List(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("len(records) = %d, want 2", len(records))
|
||||
}
|
||||
if records[0].ID != "run-3" || records[1].ID != "run-2" {
|
||||
t.Fatalf("records order = %s, %s; want run-3, run-2", records[0].ID, records[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreListIgnoresBlankLinesAndMissingFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "records.jsonl")
|
||||
store := New(path)
|
||||
ctx := context.Background()
|
||||
|
||||
records, err := store.List(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("List missing file returned error: %v", err)
|
||||
}
|
||||
if len(records) != 0 {
|
||||
t.Fatalf("len(records) = %d, want 0", len(records))
|
||||
}
|
||||
|
||||
content := []byte("\n{\"id\":\"run-1\",\"summary\":{\"id\":\"run-1\",\"target\":\"example.com:80\"}}\n\n{\"id\":\"run-2\",\"summary\":{\"id\":\"run-2\",\"target\":\"example.org:443\"}}\n")
|
||||
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
records, err = store.List(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("len(records) = %d, want 2", len(records))
|
||||
}
|
||||
if records[0].ID != "run-2" || records[1].ID != "run-1" {
|
||||
t.Fatalf("records order = %s, %s; want run-2, run-1", records[0].ID, records[1].ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"speedtest/internal/probe"
|
||||
)
|
||||
|
||||
func DefaultRunner(ctx context.Context, request TestRequest, emit func(Event) error) (probe.Summary, error) {
|
||||
return probe.Run(ctx, probe.Config{
|
||||
Target: request.Target,
|
||||
HTTPURL: request.HTTPURL,
|
||||
Duration: request.Duration,
|
||||
Interval: request.Interval,
|
||||
Timeout: request.Timeout,
|
||||
}, func(sample probe.Sample) error {
|
||||
return emit(Event{Type: "sample", Sample: &sample})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"speedtest/internal/limit"
|
||||
"speedtest/internal/probe"
|
||||
recordstore "speedtest/internal/store"
|
||||
)
|
||||
|
||||
type RecordStore interface {
|
||||
Append(ctx context.Context, record recordstore.Record) error
|
||||
List(ctx context.Context, limit int) ([]recordstore.Record, error)
|
||||
}
|
||||
|
||||
type Runner func(ctx context.Context, request TestRequest, emit func(Event) error) (probe.Summary, error)
|
||||
type ClientResolver func(ctx context.Context, ip string) (recordstore.ClientInfo, error)
|
||||
|
||||
type Options struct {
|
||||
Store RecordStore
|
||||
Runner Runner
|
||||
ClientResolver ClientResolver
|
||||
TrustProxyHeaders bool
|
||||
BandwidthLimitMbps float64
|
||||
StaticFS http.FileSystem
|
||||
StaticDir string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
store RecordStore
|
||||
runner Runner
|
||||
clientResolver ClientResolver
|
||||
trustProxyHeaders bool
|
||||
bandwidthLimiter limit.Limiter
|
||||
staticFS http.FileSystem
|
||||
staticDir string
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]TestRequest
|
||||
activeID string
|
||||
}
|
||||
|
||||
type TestRequest struct {
|
||||
ID string
|
||||
Target probe.Target
|
||||
HTTPURL string
|
||||
Duration time.Duration
|
||||
Interval time.Duration
|
||||
Timeout time.Duration
|
||||
CreatedAt time.Time
|
||||
Client recordstore.ClientInfo
|
||||
DurationSeconds int
|
||||
IntervalMillis int
|
||||
TimeoutMillis int
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Sample *probe.Sample `json:"sample,omitempty"`
|
||||
Summary *probe.Summary `json:"summary,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type recordsResponse struct {
|
||||
Records []recordstore.Record `json:"records"`
|
||||
}
|
||||
|
||||
type configResponse struct {
|
||||
BandwidthLimitMbps float64 `json:"bandwidthLimitMbps"`
|
||||
BandwidthLimitLabel string `json:"bandwidthLimitLabel"`
|
||||
}
|
||||
|
||||
type heartbeatResponse struct {
|
||||
At time.Time `json:"at"`
|
||||
BandwidthLimitMbps float64 `json:"bandwidthLimitMbps"`
|
||||
BandwidthLimitLabel string `json:"bandwidthLimitLabel"`
|
||||
ActiveTest bool `json:"activeTest"`
|
||||
}
|
||||
|
||||
type createTestResponse struct {
|
||||
ID string `json:"id"`
|
||||
EventsURL string `json:"eventsUrl"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
CompleteURL string `json:"completeUrl"`
|
||||
}
|
||||
|
||||
type transferResponse struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
DurationMS int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
type completeTestRequest struct {
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
Samples []probe.Sample `json:"samples"`
|
||||
}
|
||||
|
||||
type completeTestResponse struct {
|
||||
Summary probe.Summary `json:"summary"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type busyResponse struct {
|
||||
Error string `json:"error"`
|
||||
ActiveTestID string `json:"activeTestId,omitempty"`
|
||||
}
|
||||
|
||||
type rawTestRequest struct {
|
||||
Target string `json:"target"`
|
||||
HTTPURL string `json:"httpUrl"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
IntervalMillis int `json:"intervalMillis"`
|
||||
TimeoutMillis int `json:"timeoutMillis"`
|
||||
}
|
||||
|
||||
func New(options Options) *Server {
|
||||
return &Server{
|
||||
store: options.Store,
|
||||
runner: options.Runner,
|
||||
clientResolver: options.ClientResolver,
|
||||
trustProxyHeaders: options.TrustProxyHeaders,
|
||||
bandwidthLimiter: limit.New(options.BandwidthLimitMbps),
|
||||
staticFS: options.StaticFS,
|
||||
staticDir: options.StaticDir,
|
||||
sessions: make(map[string]TestRequest),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/config", s.handleConfig)
|
||||
mux.HandleFunc("/api/health/events", s.handleHealthEvents)
|
||||
mux.HandleFunc("/api/download", s.handleDownload)
|
||||
mux.HandleFunc("/api/upload", s.handleUpload)
|
||||
mux.HandleFunc("/api/records", s.handleRecords)
|
||||
mux.HandleFunc("/api/tests", s.handleCreateTest)
|
||||
mux.HandleFunc("/api/tests/", s.handleTestRoute)
|
||||
if s.staticFS != nil {
|
||||
mux.Handle("/", http.FileServer(s.staticFS))
|
||||
} else if s.staticDir != "" {
|
||||
mux.Handle("/", http.FileServer(http.Dir(s.staticDir)))
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
testID := strings.TrimSpace(r.URL.Query().Get("testId"))
|
||||
if !s.isActive(testID) {
|
||||
writeJSON(w, http.StatusConflict, errorResponse{Error: "没有可用的活动测速会话,请先开始测试。"})
|
||||
return
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
reader := http.MaxBytesReader(w, r.Body, 1024*1024*1024)
|
||||
defer reader.Close()
|
||||
|
||||
var received int64
|
||||
buffer := make([]byte, 32*1024)
|
||||
for {
|
||||
if err := r.Context().Err(); err != nil {
|
||||
return
|
||||
}
|
||||
n, err := reader.Read(buffer)
|
||||
if n > 0 {
|
||||
received += int64(n)
|
||||
if delay := s.bandwidthLimiter.DelayFor(received, time.Since(startedAt)); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "failed to read upload body"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, transferResponse{
|
||||
Bytes: received,
|
||||
DurationMS: time.Since(startedAt).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, configResponse{
|
||||
BandwidthLimitMbps: s.bandwidthLimiter.Mbps(),
|
||||
BandwidthLimitLabel: bandwidthLimitLabel(s.bandwidthLimiter),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
size, err := parseDownloadSize(r.URL.Query().Get("bytes"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
testID := strings.TrimSpace(r.URL.Query().Get("testId"))
|
||||
if !s.isActive(testID) {
|
||||
writeJSON(w, http.StatusConflict, errorResponse{Error: "没有可用的活动测速会话,请先开始测试。"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("X-NetStable-Bandwidth-Limit-Mbps", formatMbps(s.bandwidthLimiter.Mbps()))
|
||||
|
||||
flusher, _ := w.(http.Flusher)
|
||||
startedAt := time.Now()
|
||||
var sent int64
|
||||
buffer := make([]byte, 32*1024)
|
||||
for sent < size {
|
||||
if err := r.Context().Err(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
remaining := size - sent
|
||||
chunk := int64(len(buffer))
|
||||
if remaining < chunk {
|
||||
chunk = remaining
|
||||
}
|
||||
written, err := w.Write(buffer[:chunk])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sent += int64(written)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
if delay := s.bandwidthLimiter.DelayFor(sent, time.Since(startedAt)); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRecords(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
limit := 100
|
||||
if value := r.URL.Query().Get("limit"); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1 || parsed > 1000 {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "limit must be between 1 and 1000"})
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
records, err := s.store.List(r.Context(), limit)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "failed to load records"})
|
||||
return
|
||||
}
|
||||
records = sanitizeRecords(records)
|
||||
writeJSON(w, http.StatusOK, recordsResponse{Records: records})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
limit := 0
|
||||
if value := r.URL.Query().Get("limit"); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1 || parsed > 100 {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "limit must be between 1 and 100"})
|
||||
return
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for sent := 0; ; sent++ {
|
||||
if err := writeSSEData(w, "heartbeat", heartbeatResponse{
|
||||
At: time.Now().UTC(),
|
||||
BandwidthLimitMbps: s.bandwidthLimiter.Mbps(),
|
||||
BandwidthLimitLabel: bandwidthLimitLabel(s.bandwidthLimiter),
|
||||
ActiveTest: s.hasActiveTest(),
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
if limit > 0 && sent+1 >= limit {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseDownloadSize(value string) (int64, error) {
|
||||
if value == "" {
|
||||
return 10 * 1024 * 1024, nil
|
||||
}
|
||||
size, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || size < 1 || size > 1024*1024*1024 {
|
||||
return 0, errors.New("bytes must be between 1 and 1073741824")
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func bandwidthLimitLabel(limiter limit.Limiter) string {
|
||||
if !limiter.Enabled() {
|
||||
return "不限速"
|
||||
}
|
||||
return formatMbps(limiter.Mbps()) + " Mbps"
|
||||
}
|
||||
|
||||
func formatMbps(mbps float64) string {
|
||||
if mbps == math.Trunc(mbps) {
|
||||
return strconv.FormatInt(int64(mbps), 10)
|
||||
}
|
||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", mbps), "0"), ".")
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var raw rawTestRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid JSON request"})
|
||||
return
|
||||
}
|
||||
|
||||
request, err := normalizeTestRequest(raw)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
request.ID = newID()
|
||||
request.CreatedAt = time.Now().UTC()
|
||||
request.Client = s.resolveClient(r.Context(), clientIPFromRequest(r, s.trustProxyHeaders))
|
||||
|
||||
s.mu.Lock()
|
||||
s.expireActiveLocked(time.Now().UTC())
|
||||
if s.activeID != "" {
|
||||
activeID := s.activeID
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, http.StatusConflict, busyResponse{
|
||||
Error: "有其他用户正在测速,请稍等。",
|
||||
ActiveTestID: activeID,
|
||||
})
|
||||
return
|
||||
}
|
||||
s.sessions[request.ID] = request
|
||||
s.activeID = request.ID
|
||||
s.mu.Unlock()
|
||||
|
||||
writeJSON(w, http.StatusAccepted, createTestResponse{
|
||||
ID: request.ID,
|
||||
EventsURL: "/api/tests/" + request.ID + "/events",
|
||||
DownloadURL: "/api/download?testId=" + request.ID,
|
||||
UploadURL: "/api/upload?testId=" + request.ID,
|
||||
CompleteURL: "/api/tests/" + request.ID + "/complete",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleTestRoute(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/events"):
|
||||
s.handleTestEvents(w, r)
|
||||
case strings.HasSuffix(r.URL.Path, "/complete"):
|
||||
s.handleCompleteTest(w, r)
|
||||
default:
|
||||
writeJSON(w, http.StatusNotFound, errorResponse{Error: "not found"})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCompleteTest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
id, ok := completeSessionID(r.URL.Path)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, errorResponse{Error: "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var raw completeTestRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*1024*1024))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid JSON request"})
|
||||
return
|
||||
}
|
||||
|
||||
request, exists := s.takeSession(id)
|
||||
if !exists {
|
||||
writeJSON(w, http.StatusNotFound, errorResponse{Error: "test session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
summary := summarizeBrowserSamples(request, raw)
|
||||
if s.store != nil {
|
||||
if err := s.store.Append(r.Context(), recordstore.Record{
|
||||
ID: request.ID,
|
||||
Client: request.Client,
|
||||
Summary: summary,
|
||||
Samples: raw.Samples,
|
||||
CreatedAt: request.CreatedAt,
|
||||
}); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "failed to persist test record"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, completeTestResponse{Summary: summary})
|
||||
}
|
||||
|
||||
func (s *Server) handleTestEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, errorResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
id, ok := eventSessionID(r.URL.Path)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusNotFound, errorResponse{Error: "not found"})
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
request, exists := s.sessions[id]
|
||||
if exists {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !exists {
|
||||
writeJSON(w, http.StatusNotFound, errorResponse{Error: "test session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, _ := w.(http.Flusher)
|
||||
var samples []probe.Sample
|
||||
emit := func(event Event) error {
|
||||
if event.Sample != nil {
|
||||
sample := *event.Sample
|
||||
if sample.LatencyMS == 0 && sample.Latency > 0 {
|
||||
sample.LatencyMS = float64(sample.Latency.Microseconds()) / 1000
|
||||
}
|
||||
samples = append(samples, sample)
|
||||
event.Sample = &sample
|
||||
}
|
||||
if err := writeSSE(w, event); err != nil {
|
||||
return err
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.runner == nil {
|
||||
_ = emit(Event{Type: "error", Error: "test runner is not configured"})
|
||||
s.releaseActive(request.ID)
|
||||
return
|
||||
}
|
||||
defer s.releaseActive(request.ID)
|
||||
|
||||
summary, err := s.runner(r.Context(), request, emit)
|
||||
if err != nil {
|
||||
_ = emit(Event{Type: "error", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
summary.ID = request.ID
|
||||
summary.HTTPURL = request.HTTPURL
|
||||
|
||||
if s.store != nil {
|
||||
if err := s.store.Append(r.Context(), recordstore.Record{
|
||||
ID: request.ID,
|
||||
Client: request.Client,
|
||||
Summary: summary,
|
||||
Samples: samples,
|
||||
CreatedAt: request.CreatedAt,
|
||||
}); err != nil {
|
||||
_ = emit(Event{Type: "error", Error: "failed to persist test record"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_ = emit(Event{Type: "summary", Summary: &summary})
|
||||
}
|
||||
|
||||
func normalizeTestRequest(raw rawTestRequest) (TestRequest, error) {
|
||||
target, err := probe.ParseTarget(raw.Target)
|
||||
if err != nil {
|
||||
return TestRequest{}, fmt.Errorf("invalid target: %w", err)
|
||||
}
|
||||
|
||||
durationSeconds := raw.DurationSeconds
|
||||
if durationSeconds == 0 {
|
||||
durationSeconds = 30
|
||||
}
|
||||
if durationSeconds < 1 || durationSeconds > 600 {
|
||||
return TestRequest{}, errors.New("durationSeconds must be between 1 and 600")
|
||||
}
|
||||
|
||||
intervalMillis := raw.IntervalMillis
|
||||
if intervalMillis == 0 {
|
||||
intervalMillis = 1000
|
||||
}
|
||||
if intervalMillis < 100 || intervalMillis > 60000 {
|
||||
return TestRequest{}, errors.New("intervalMillis must be between 100 and 60000")
|
||||
}
|
||||
|
||||
timeoutMillis := raw.TimeoutMillis
|
||||
if timeoutMillis == 0 {
|
||||
timeoutMillis = 3000
|
||||
}
|
||||
if timeoutMillis < 100 || timeoutMillis > 30000 {
|
||||
return TestRequest{}, errors.New("timeoutMillis must be between 100 and 30000")
|
||||
}
|
||||
|
||||
httpURL := strings.TrimSpace(raw.HTTPURL)
|
||||
if httpURL != "" {
|
||||
parsed, err := url.Parse(httpURL)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return TestRequest{}, errors.New("httpUrl must be a valid http or https URL")
|
||||
}
|
||||
}
|
||||
|
||||
return TestRequest{
|
||||
Target: target,
|
||||
HTTPURL: httpURL,
|
||||
Duration: time.Duration(durationSeconds) * time.Second,
|
||||
Interval: time.Duration(intervalMillis) * time.Millisecond,
|
||||
Timeout: time.Duration(timeoutMillis) * time.Millisecond,
|
||||
DurationSeconds: durationSeconds,
|
||||
IntervalMillis: intervalMillis,
|
||||
TimeoutMillis: timeoutMillis,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) releaseActive(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.activeID == id {
|
||||
s.activeID = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) takeSession(id string) (TestRequest, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
request, exists := s.sessions[id]
|
||||
if exists {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
if s.activeID == id {
|
||||
s.activeID = ""
|
||||
}
|
||||
return request, exists
|
||||
}
|
||||
|
||||
func (s *Server) hasActiveTest() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.expireActiveLocked(time.Now().UTC())
|
||||
return s.activeID != ""
|
||||
}
|
||||
|
||||
func (s *Server) expireActiveLocked(now time.Time) {
|
||||
if s.activeID == "" {
|
||||
return
|
||||
}
|
||||
request, exists := s.sessions[s.activeID]
|
||||
if !exists {
|
||||
s.activeID = ""
|
||||
return
|
||||
}
|
||||
if now.Sub(request.CreatedAt) > request.Duration*2+60*time.Second {
|
||||
delete(s.sessions, s.activeID)
|
||||
s.activeID = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) isActive(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.activeID == id
|
||||
}
|
||||
|
||||
func (s *Server) resolveClient(ctx context.Context, ip string) recordstore.ClientInfo {
|
||||
if ip == "" {
|
||||
return normalizeClientInfo(recordstore.ClientInfo{IP: "unknown", Source: "request"}, "")
|
||||
}
|
||||
if s.clientResolver == nil {
|
||||
return normalizeClientInfo(recordstore.ClientInfo{IP: ip, Source: "request"}, ip)
|
||||
}
|
||||
info, err := s.clientResolver(ctx, ip)
|
||||
if err != nil {
|
||||
return normalizeClientInfo(recordstore.ClientInfo{IP: ip, Source: "request"}, ip)
|
||||
}
|
||||
if info.IP == "" {
|
||||
info.IP = ip
|
||||
}
|
||||
return normalizeClientInfo(info, ip)
|
||||
}
|
||||
|
||||
func sanitizeRecords(records []recordstore.Record) []recordstore.Record {
|
||||
out := make([]recordstore.Record, len(records))
|
||||
for i, record := range records {
|
||||
record.Client = normalizeClientInfo(record.Client, record.Client.IP)
|
||||
out[i] = record
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeClientInfo(info recordstore.ClientInfo, fallbackIP string) recordstore.ClientInfo {
|
||||
if strings.TrimSpace(info.IP) == "" {
|
||||
info.IP = fallbackIP
|
||||
}
|
||||
info.IP = anonymizeIP(info.IP)
|
||||
if strings.TrimSpace(info.ISP) == "" {
|
||||
info.ISP = "未知运营商"
|
||||
}
|
||||
if strings.TrimSpace(info.Source) == "" {
|
||||
info.Source = "request"
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func anonymizeIP(value string) string {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return "unknown"
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(raw); err == nil {
|
||||
raw = host
|
||||
}
|
||||
raw = strings.Trim(raw, "[]")
|
||||
ip := net.ParseIP(raw)
|
||||
if ip == nil {
|
||||
return raw
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return fmt.Sprintf("%d.%d.%d.*", ipv4[0], ipv4[1], ipv4[2])
|
||||
}
|
||||
ipv6 := ip.To16()
|
||||
if ipv6 == nil {
|
||||
return raw
|
||||
}
|
||||
return fmt.Sprintf("%x:%x:%x:*",
|
||||
uint16(ipv6[0])<<8|uint16(ipv6[1]),
|
||||
uint16(ipv6[2])<<8|uint16(ipv6[3]),
|
||||
uint16(ipv6[4])<<8|uint16(ipv6[5]),
|
||||
)
|
||||
}
|
||||
|
||||
func clientIPFromRequest(r *http.Request, trustProxyHeaders bool) string {
|
||||
if trustProxyHeaders {
|
||||
for _, header := range []string{"CF-Connecting-IP", "X-Real-IP"} {
|
||||
value := strings.TrimSpace(r.Header.Get(header))
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" {
|
||||
ip := strings.TrimSpace(strings.Split(forwarded, ",")[0])
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
|
||||
func eventSessionID(path string) (string, bool) {
|
||||
if !strings.HasPrefix(path, "/api/tests/") || !strings.HasSuffix(path, "/events") {
|
||||
return "", false
|
||||
}
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/tests/"), "/events")
|
||||
id = strings.Trim(id, "/")
|
||||
return id, id != ""
|
||||
}
|
||||
|
||||
func completeSessionID(path string) (string, bool) {
|
||||
if !strings.HasPrefix(path, "/api/tests/") || !strings.HasSuffix(path, "/complete") {
|
||||
return "", false
|
||||
}
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/tests/"), "/complete")
|
||||
id = strings.Trim(id, "/")
|
||||
return id, id != ""
|
||||
}
|
||||
|
||||
func summarizeBrowserSamples(request TestRequest, raw completeTestRequest) probe.Summary {
|
||||
startedAt := raw.StartedAt
|
||||
if startedAt.IsZero() {
|
||||
startedAt = request.CreatedAt
|
||||
}
|
||||
finishedAt := raw.FinishedAt
|
||||
if finishedAt.IsZero() || finishedAt.Before(startedAt) {
|
||||
finishedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
summary := probe.Summary{
|
||||
ID: request.ID,
|
||||
Target: request.Target.Address,
|
||||
HTTPURL: request.HTTPURL,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: finishedAt,
|
||||
DurationMS: finishedAt.Sub(startedAt).Milliseconds(),
|
||||
Samples: len(raw.Samples),
|
||||
}
|
||||
|
||||
var latencySum float64
|
||||
var latencyCount int
|
||||
var minLatency float64
|
||||
var maxLatency float64
|
||||
var lastLatency float64
|
||||
var jitterSum float64
|
||||
var jitterPairs int
|
||||
var downloadSum float64
|
||||
var downloadCount int
|
||||
var uploadSum float64
|
||||
var uploadCount int
|
||||
|
||||
for _, sample := range raw.Samples {
|
||||
if !sample.Success {
|
||||
summary.Failures++
|
||||
continue
|
||||
}
|
||||
summary.Successes++
|
||||
switch sample.Kind {
|
||||
case "latency":
|
||||
latency := sample.LatencyMS
|
||||
latencySum += latency
|
||||
latencyCount++
|
||||
if latencyCount == 1 || latency < minLatency {
|
||||
minLatency = latency
|
||||
}
|
||||
if latency > maxLatency {
|
||||
maxLatency = latency
|
||||
}
|
||||
if latencyCount > 1 {
|
||||
jitterSum += math.Abs(latency - lastLatency)
|
||||
jitterPairs++
|
||||
}
|
||||
lastLatency = latency
|
||||
case "download":
|
||||
downloadSum += sample.Mbps
|
||||
downloadCount++
|
||||
if sample.Mbps > summary.MaxDownloadMbps {
|
||||
summary.MaxDownloadMbps = sample.Mbps
|
||||
}
|
||||
case "upload":
|
||||
uploadSum += sample.Mbps
|
||||
uploadCount++
|
||||
if sample.Mbps > summary.MaxUploadMbps {
|
||||
summary.MaxUploadMbps = sample.Mbps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if summary.Samples > 0 {
|
||||
summary.PacketLossPct = round2(float64(summary.Failures) / float64(summary.Samples) * 100)
|
||||
}
|
||||
if latencyCount > 0 {
|
||||
summary.AvgLatencyMS = round2(latencySum / float64(latencyCount))
|
||||
summary.MinLatencyMS = round2(minLatency)
|
||||
summary.MaxLatencyMS = round2(maxLatency)
|
||||
}
|
||||
if jitterPairs > 0 {
|
||||
summary.JitterMS = round2(jitterSum / float64(jitterPairs))
|
||||
}
|
||||
if downloadCount > 0 {
|
||||
summary.AvgDownloadMbps = round2(downloadSum / float64(downloadCount))
|
||||
}
|
||||
if uploadCount > 0 {
|
||||
summary.AvgUploadMbps = round2(uploadSum / float64(uploadCount))
|
||||
}
|
||||
summary.Score = browserScore(summary)
|
||||
return summary
|
||||
}
|
||||
|
||||
func browserScore(summary probe.Summary) float64 {
|
||||
if summary.Samples == 0 {
|
||||
return 0
|
||||
}
|
||||
score := 100.0
|
||||
score -= summary.PacketLossPct * 1.5
|
||||
score -= math.Min(summary.AvgLatencyMS/20, 20)
|
||||
score -= math.Min(summary.JitterMS/5, 15)
|
||||
if summary.AvgDownloadMbps == 0 {
|
||||
score -= 10
|
||||
}
|
||||
if summary.AvgUploadMbps == 0 {
|
||||
score -= 10
|
||||
}
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
return round2(score)
|
||||
}
|
||||
|
||||
func round2(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, event Event) error {
|
||||
eventType := event.Type
|
||||
if eventType == "" {
|
||||
eventType = "message"
|
||||
}
|
||||
|
||||
var data any
|
||||
switch {
|
||||
case event.Sample != nil:
|
||||
data = event.Sample
|
||||
case event.Summary != nil:
|
||||
data = event.Summary
|
||||
case event.Error != "":
|
||||
data = errorResponse{Error: event.Error}
|
||||
default:
|
||||
data = event
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeSSEPayload(w, eventType, payload)
|
||||
}
|
||||
|
||||
func writeSSEData(w http.ResponseWriter, eventType string, data any) error {
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeSSEPayload(w, eventType, payload)
|
||||
}
|
||||
|
||||
func writeSSEPayload(w http.ResponseWriter, eventType string, payload []byte) error {
|
||||
if eventType == "" {
|
||||
eventType = "message"
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
var bytes [8]byte
|
||||
if _, err := rand.Read(bytes[:]); err == nil {
|
||||
return hex.EncodeToString(bytes[:])
|
||||
}
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"speedtest/internal/probe"
|
||||
recordstore "speedtest/internal/store"
|
||||
)
|
||||
|
||||
func TestRecordsEndpointReturnsRecords(t *testing.T) {
|
||||
store := &memoryStore{
|
||||
records: []recordstore.Record{
|
||||
{
|
||||
ID: "run-2",
|
||||
Client: recordstore.ClientInfo{IP: "203.0.113.20", ISP: "Example Mobile", Region: "Beijing"},
|
||||
Summary: probe.Summary{ID: "run-2", Target: "example.org:443", Score: 88},
|
||||
Samples: []probe.Sample{{Kind: "tcp", Success: true, LatencyMS: 40}},
|
||||
},
|
||||
{
|
||||
ID: "run-1",
|
||||
Client: recordstore.ClientInfo{IP: "198.51.100.10", ISP: "Example Telecom", Region: "Shanghai"},
|
||||
Summary: probe.Summary{ID: "run-1", Target: "example.com:80", Score: 95},
|
||||
Samples: []probe.Sample{{Kind: "tcp", Success: true, LatencyMS: 30}},
|
||||
},
|
||||
},
|
||||
}
|
||||
server := New(Options{Store: store, Runner: noopRunner})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/records?limit=2", nil)
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var payload recordsResponse
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(payload.Records) != 2 {
|
||||
t.Fatalf("len(records) = %d, want 2", len(payload.Records))
|
||||
}
|
||||
if payload.Records[0].ID != "run-2" {
|
||||
t.Fatalf("first record id = %q, want run-2", payload.Records[0].ID)
|
||||
}
|
||||
if payload.Records[0].Client.IP != "203.0.113.*" || payload.Records[0].Client.ISP != "Example Mobile" {
|
||||
t.Fatalf("first record client = %#v, want anonymized client data", payload.Records[0].Client)
|
||||
}
|
||||
if len(payload.Records[0].Samples) != 1 {
|
||||
t.Fatalf("first record samples = %d, want 1", len(payload.Records[0].Samples))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordsEndpointAnonymizesClientIPAndFillsMissingISP(t *testing.T) {
|
||||
store := &memoryStore{
|
||||
records: []recordstore.Record{
|
||||
{
|
||||
ID: "run-1",
|
||||
Client: recordstore.ClientInfo{IP: "203.0.113.20", Region: "Beijing"},
|
||||
Summary: probe.Summary{ID: "run-1", Target: "example.com:80", Score: 95},
|
||||
},
|
||||
},
|
||||
}
|
||||
server := New(Options{Store: store, Runner: noopRunner})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/records?limit=1", nil)
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var payload recordsResponse
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
client := payload.Records[0].Client
|
||||
if client.IP != "203.0.113.*" {
|
||||
t.Fatalf("client IP = %q, want anonymized 203.0.113.*", client.IP)
|
||||
}
|
||||
if client.ISP != "未知运营商" {
|
||||
t.Fatalf("client ISP = %q, want 未知运营商", client.ISP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigEndpointReturnsBandwidthLimit(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, BandwidthLimitMbps: 80})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var payload configResponse
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.BandwidthLimitMbps != 80 {
|
||||
t.Fatalf("BandwidthLimitMbps = %.2f, want 80", payload.BandwidthLimitMbps)
|
||||
}
|
||||
if payload.BandwidthLimitLabel != "80 Mbps" {
|
||||
t.Fatalf("BandwidthLimitLabel = %q, want 80 Mbps", payload.BandwidthLimitLabel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthEventsStreamsHeartbeat(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, BandwidthLimitMbps: 30})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health/events?limit=1", nil)
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
if contentType := res.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/event-stream") {
|
||||
t.Fatalf("Content-Type = %q, want text/event-stream", contentType)
|
||||
}
|
||||
body := res.Body.String()
|
||||
if !strings.Contains(body, "event: heartbeat") || !strings.Contains(body, `"bandwidthLimitLabel":"30 Mbps"`) {
|
||||
t.Fatalf("heartbeat body = %q, want heartbeat with bandwidth label", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadEndpointStreamsRequestedBytesAndAdvertisesLimit(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, BandwidthLimitMbps: 80})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
|
||||
createRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(createRes, createReq)
|
||||
if createRes.Code != http.StatusAccepted {
|
||||
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
|
||||
}
|
||||
var created createTestResponse
|
||||
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create response: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, created.DownloadURL+"&bytes=1024", nil)
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
if res.Body.Len() != 1024 {
|
||||
t.Fatalf("download length = %d, want 1024", res.Body.Len())
|
||||
}
|
||||
if got := res.Header().Get("X-NetStable-Bandwidth-Limit-Mbps"); got != "80" {
|
||||
t.Fatalf("limit header = %q, want 80", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadEndpointRequiresActiveTestID(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/download?bytes=1024", nil)
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadEndpointAcceptsActiveSession(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, BandwidthLimitMbps: 80})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
|
||||
createRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(createRes, createReq)
|
||||
if createRes.Code != http.StatusAccepted {
|
||||
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
|
||||
}
|
||||
var created createTestResponse
|
||||
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create response: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, created.UploadURL, bytes.NewBuffer(make([]byte, 2048)))
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var payload transferResponse
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode upload response: %v", err)
|
||||
}
|
||||
if payload.Bytes != 2048 {
|
||||
t.Fatalf("uploaded bytes = %d, want 2048", payload.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadEndpointRequiresActiveTestID(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/upload?bytes=1024", bytes.NewBuffer(make([]byte, 1024)))
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadEndpointRejectsInvalidByteCount(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/download?bytes=0", nil)
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTestRejectsInvalidTarget(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com:abc"}`))
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
if !strings.Contains(res.Body.String(), "invalid target") {
|
||||
t.Fatalf("body = %q, want invalid target error", res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPUsesRemoteAddrUnlessProxyHeadersAreTrusted(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "198.51.100.10:54321"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.20")
|
||||
req.Header.Set("X-Real-IP", "203.0.113.30")
|
||||
|
||||
if got := clientIPFromRequest(req, false); got != "198.51.100.10" {
|
||||
t.Fatalf("clientIPFromRequest untrusted = %q, want RemoteAddr IP", got)
|
||||
}
|
||||
if got := clientIPFromRequest(req, true); got != "203.0.113.30" {
|
||||
t.Fatalf("clientIPFromRequest trusted = %q, want X-Real-IP", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTestReturnsSessionIDAndEventsURL(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, ClientResolver: staticResolver})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1,"intervalMillis":200,"timeoutMillis":300}`))
|
||||
req.RemoteAddr = "198.51.100.10:54321"
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
var payload createTestResponse
|
||||
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.ID == "" {
|
||||
t.Fatal("payload.ID is empty")
|
||||
}
|
||||
if payload.EventsURL != "/api/tests/"+payload.ID+"/events" {
|
||||
t.Fatalf("EventsURL = %q, want /api/tests/%s/events", payload.EventsURL, payload.ID)
|
||||
}
|
||||
if payload.DownloadURL != "/api/download?testId="+payload.ID {
|
||||
t.Fatalf("DownloadURL = %q, want /api/download?testId=%s", payload.DownloadURL, payload.ID)
|
||||
}
|
||||
if payload.UploadURL != "/api/upload?testId="+payload.ID {
|
||||
t.Fatalf("UploadURL = %q, want /api/upload?testId=%s", payload.UploadURL, payload.ID)
|
||||
}
|
||||
if payload.CompleteURL != "/api/tests/"+payload.ID+"/complete" {
|
||||
t.Fatalf("CompleteURL = %q, want /api/tests/%s/complete", payload.CompleteURL, payload.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTestReturnsBusyWhenAnotherSessionIsActive(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
|
||||
|
||||
firstReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
|
||||
firstRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(firstRes, firstReq)
|
||||
if firstRes.Code != http.StatusAccepted {
|
||||
t.Fatalf("first status = %d, want 202; body=%s", firstRes.Code, firstRes.Body.String())
|
||||
}
|
||||
|
||||
secondReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
|
||||
secondRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(secondRes, secondReq)
|
||||
if secondRes.Code != http.StatusConflict {
|
||||
t.Fatalf("second status = %d, want 409; body=%s", secondRes.Code, secondRes.Body.String())
|
||||
}
|
||||
if !strings.Contains(secondRes.Body.String(), "正在测速") {
|
||||
t.Fatalf("busy response = %q, want Chinese wait message", secondRes.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserSessionExpiresAfterDownloadAndUploadWindows(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
|
||||
|
||||
firstReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":30}`))
|
||||
firstRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(firstRes, firstReq)
|
||||
if firstRes.Code != http.StatusAccepted {
|
||||
t.Fatalf("first status = %d, want 202; body=%s", firstRes.Code, firstRes.Body.String())
|
||||
}
|
||||
|
||||
server.mu.Lock()
|
||||
request := server.sessions[server.activeID]
|
||||
request.CreatedAt = time.Now().UTC().Add(-90 * time.Second)
|
||||
server.sessions[server.activeID] = request
|
||||
server.mu.Unlock()
|
||||
|
||||
secondReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
|
||||
secondRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(secondRes, secondReq)
|
||||
if secondRes.Code != http.StatusConflict {
|
||||
t.Fatalf("second status = %d, want 409 while 30s download + 30s upload window is active; body=%s", secondRes.Code, secondRes.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEStreamsSamplesSummaryAndPersistsRecord(t *testing.T) {
|
||||
store := &memoryStore{}
|
||||
start := time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC)
|
||||
runner := func(ctx context.Context, request TestRequest, emit func(Event) error) (probe.Summary, error) {
|
||||
sample := probe.Sample{
|
||||
At: start,
|
||||
Kind: "tcp",
|
||||
Success: true,
|
||||
Latency: 25 * time.Millisecond,
|
||||
LatencyMS: 25,
|
||||
}
|
||||
if err := emit(Event{Type: "sample", Sample: &sample}); err != nil {
|
||||
return probe.Summary{}, err
|
||||
}
|
||||
return probe.Summary{
|
||||
Target: "example.com:80",
|
||||
StartedAt: start,
|
||||
FinishedAt: start.Add(time.Second),
|
||||
Samples: 1,
|
||||
Successes: 1,
|
||||
Score: 98,
|
||||
}, nil
|
||||
}
|
||||
server := New(Options{Store: store, Runner: runner, ClientResolver: staticResolver})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
|
||||
createReq.RemoteAddr = "203.0.113.20:54321"
|
||||
createRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(createRes, createReq)
|
||||
if createRes.Code != http.StatusAccepted {
|
||||
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
|
||||
}
|
||||
var created createTestResponse
|
||||
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create response: %v", err)
|
||||
}
|
||||
|
||||
eventsReq := httptest.NewRequest(http.MethodGet, created.EventsURL, nil)
|
||||
eventsRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(eventsRes, eventsReq)
|
||||
|
||||
if eventsRes.Code != http.StatusOK {
|
||||
t.Fatalf("events status = %d, want 200; body=%s", eventsRes.Code, eventsRes.Body.String())
|
||||
}
|
||||
if contentType := eventsRes.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/event-stream") {
|
||||
t.Fatalf("Content-Type = %q, want text/event-stream", contentType)
|
||||
}
|
||||
body := eventsRes.Body.String()
|
||||
if !strings.Contains(body, "event: sample") || !strings.Contains(body, `"latencyMs":25`) {
|
||||
t.Fatalf("events body missing sample event: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "event: summary") || !strings.Contains(body, `"score":98`) {
|
||||
t.Fatalf("events body missing summary event: %s", body)
|
||||
}
|
||||
|
||||
records := store.snapshot()
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("persisted records = %d, want 1", len(records))
|
||||
}
|
||||
if records[0].ID != created.ID {
|
||||
t.Fatalf("persisted id = %q, want %q", records[0].ID, created.ID)
|
||||
}
|
||||
if records[0].Client.IP != "203.0.113.*" || records[0].Client.Region != "Beijing" || records[0].Client.ISP != "Example Mobile" {
|
||||
t.Fatalf("persisted client = %#v, want enriched client data", records[0].Client)
|
||||
}
|
||||
if len(records[0].Samples) != 1 || records[0].Samples[0].LatencyMS != 25 {
|
||||
t.Fatalf("persisted samples = %#v, want sample curve", records[0].Samples)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionLockReleasesAfterSSECompletes(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
|
||||
|
||||
firstReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":1}`))
|
||||
firstRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(firstRes, firstReq)
|
||||
if firstRes.Code != http.StatusAccepted {
|
||||
t.Fatalf("first status = %d, want 202; body=%s", firstRes.Code, firstRes.Body.String())
|
||||
}
|
||||
var first createTestResponse
|
||||
if err := json.Unmarshal(firstRes.Body.Bytes(), &first); err != nil {
|
||||
t.Fatalf("decode first response: %v", err)
|
||||
}
|
||||
|
||||
eventsReq := httptest.NewRequest(http.MethodGet, first.EventsURL, nil)
|
||||
eventsRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(eventsRes, eventsReq)
|
||||
if eventsRes.Code != http.StatusOK {
|
||||
t.Fatalf("events status = %d, want 200; body=%s", eventsRes.Code, eventsRes.Body.String())
|
||||
}
|
||||
|
||||
nextReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
|
||||
nextRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(nextRes, nextReq)
|
||||
if nextRes.Code != http.StatusAccepted {
|
||||
t.Fatalf("next status = %d, want 202 after prior session completes; body=%s", nextRes.Code, nextRes.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteEndpointPersistsBrowserSpeedRecordAndReleasesLock(t *testing.T) {
|
||||
store := &memoryStore{}
|
||||
server := New(Options{Store: store, Runner: noopRunner, ClientResolver: staticResolver})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.com","durationSeconds":30}`))
|
||||
createReq.RemoteAddr = "203.0.113.20:54321"
|
||||
createRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(createRes, createReq)
|
||||
if createRes.Code != http.StatusAccepted {
|
||||
t.Fatalf("create status = %d, want 202; body=%s", createRes.Code, createRes.Body.String())
|
||||
}
|
||||
var created createTestResponse
|
||||
if err := json.Unmarshal(createRes.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create response: %v", err)
|
||||
}
|
||||
|
||||
body := `{
|
||||
"startedAt":"2026-06-05T08:00:00Z",
|
||||
"finishedAt":"2026-06-05T08:00:05Z",
|
||||
"samples":[
|
||||
{"at":"2026-06-05T08:00:01Z","kind":"latency","success":true,"latencyMs":20},
|
||||
{"at":"2026-06-05T08:00:02Z","kind":"download","success":true,"bytes":4194304,"mbps":75.5},
|
||||
{"at":"2026-06-05T08:00:03Z","kind":"upload","success":true,"bytes":2097152,"mbps":42.25}
|
||||
]
|
||||
}`
|
||||
completeReq := httptest.NewRequest(http.MethodPost, created.CompleteURL, jsonBody(body))
|
||||
completeRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(completeRes, completeReq)
|
||||
if completeRes.Code != http.StatusOK {
|
||||
t.Fatalf("complete status = %d, want 200; body=%s", completeRes.Code, completeRes.Body.String())
|
||||
}
|
||||
var completed completeTestResponse
|
||||
if err := json.Unmarshal(completeRes.Body.Bytes(), &completed); err != nil {
|
||||
t.Fatalf("decode complete response: %v", err)
|
||||
}
|
||||
if completed.Summary.AvgDownloadMbps != 75.5 || completed.Summary.AvgUploadMbps != 42.25 {
|
||||
t.Fatalf("summary speeds = %.2f/%.2f, want 75.5/42.25", completed.Summary.AvgDownloadMbps, completed.Summary.AvgUploadMbps)
|
||||
}
|
||||
|
||||
records := store.snapshot()
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %d, want 1", len(records))
|
||||
}
|
||||
if records[0].Client.IP != "203.0.113.*" {
|
||||
t.Fatalf("record client IP = %q, want 203.0.113.*", records[0].Client.IP)
|
||||
}
|
||||
if len(records[0].Samples) != 3 {
|
||||
t.Fatalf("record samples = %d, want 3", len(records[0].Samples))
|
||||
}
|
||||
|
||||
nextReq := httptest.NewRequest(http.MethodPost, "/api/tests", jsonBody(`{"target":"example.org","durationSeconds":1}`))
|
||||
nextRes := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(nextRes, nextReq)
|
||||
if nextRes.Code != http.StatusAccepted {
|
||||
t.Fatalf("next status = %d, want 202 after complete releases lock; body=%s", nextRes.Code, nextRes.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEUnknownSessionReturnsNotFound(t *testing.T) {
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tests/missing/events", nil)
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", res.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerServesStaticFilesFromFS(t *testing.T) {
|
||||
staticFS := http.FS(fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte("<html>dashboard</html>")},
|
||||
})
|
||||
server := New(Options{Store: &memoryStore{}, Runner: noopRunner, StaticFS: staticFS})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
if !strings.Contains(res.Body.String(), "dashboard") {
|
||||
t.Fatalf("body = %q, want dashboard content", res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubFSHelperReturnsStaticRoot(t *testing.T) {
|
||||
staticFS := fstest.MapFS{
|
||||
"static/index.html": &fstest.MapFile{Data: []byte("<html>embedded</html>")},
|
||||
}
|
||||
root, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
t.Fatalf("fs.Sub: %v", err)
|
||||
}
|
||||
if _, err := root.Open("index.html"); err != nil {
|
||||
t.Fatalf("open index from sub fs: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRunnerEmitsSampleEvents(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
target, err := probe.ParseTarget(listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTarget: %v", err)
|
||||
}
|
||||
|
||||
var sampleEvents int
|
||||
summary, err := DefaultRunner(context.Background(), TestRequest{
|
||||
Target: target,
|
||||
Duration: 80 * time.Millisecond,
|
||||
Interval: 20 * time.Millisecond,
|
||||
Timeout: 100 * time.Millisecond,
|
||||
}, func(event Event) error {
|
||||
if event.Type == "sample" && event.Sample != nil {
|
||||
sampleEvents++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DefaultRunner returned error: %v", err)
|
||||
}
|
||||
if sampleEvents == 0 {
|
||||
t.Fatal("no sample events emitted")
|
||||
}
|
||||
if summary.Successes == 0 {
|
||||
t.Fatalf("summary = %#v, want successes", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonBody(body string) *bytes.Buffer {
|
||||
return bytes.NewBufferString(body)
|
||||
}
|
||||
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
records []recordstore.Record
|
||||
}
|
||||
|
||||
func (s *memoryStore) Append(ctx context.Context, record recordstore.Record) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.records = append([]recordstore.Record{record}, s.records...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) List(ctx context.Context, limit int) ([]recordstore.Record, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if limit < 1 || limit > len(s.records) {
|
||||
limit = len(s.records)
|
||||
}
|
||||
out := make([]recordstore.Record, limit)
|
||||
copy(out, s.records[:limit])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *memoryStore) snapshot() []recordstore.Record {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]recordstore.Record, len(s.records))
|
||||
copy(out, s.records)
|
||||
return out
|
||||
}
|
||||
|
||||
func noopRunner(ctx context.Context, request TestRequest, emit func(Event) error) (probe.Summary, error) {
|
||||
return probe.Summary{Target: request.Target.Address, StartedAt: time.Now(), FinishedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func staticResolver(ctx context.Context, ip string) (recordstore.ClientInfo, error) {
|
||||
return recordstore.ClientInfo{
|
||||
IP: ip,
|
||||
ISP: "Example Mobile",
|
||||
Region: "Beijing",
|
||||
Country: "China",
|
||||
City: "Beijing",
|
||||
ASN: "AS64512",
|
||||
Source: "test",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package webassets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var embedded embed.FS
|
||||
|
||||
func FileSystem() http.FileSystem {
|
||||
staticRoot, err := fs.Sub(embedded, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return http.FS(staticRoot)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package webassets
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileSystemServesEmbeddedIndex(t *testing.T) {
|
||||
file, err := FileSystem().Open("index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("open embedded index.html: %v", err)
|
||||
}
|
||||
_ = file.Close()
|
||||
}
|
||||
|
||||
func TestStaticAssetsIncludeMobileRecordLayout(t *testing.T) {
|
||||
css, err := readAsset("styles.css")
|
||||
if err != nil {
|
||||
t.Fatalf("read styles.css: %v", err)
|
||||
}
|
||||
if !contains(string(css), "@media (max-width: 560px)") || !contains(string(css), "content: attr(data-label)") {
|
||||
t.Fatalf("styles.css missing mobile record layout rules")
|
||||
}
|
||||
|
||||
app, err := readAsset("app.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read app.js: %v", err)
|
||||
}
|
||||
if !contains(string(app), `data-label="脱敏 IP"`) {
|
||||
t.Fatalf("app.js missing mobile record data labels")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(text, pattern string) bool {
|
||||
return strings.Contains(text, pattern)
|
||||
}
|
||||
|
||||
func readAsset(path string) (string, error) {
|
||||
file, err := FileSystem().Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
const state = {
|
||||
source: null,
|
||||
samples: [],
|
||||
records: [],
|
||||
};
|
||||
|
||||
const els = {
|
||||
form: document.getElementById("testForm"),
|
||||
startButton: document.getElementById("startButton"),
|
||||
refreshButton: document.getElementById("refreshButton"),
|
||||
notice: document.getElementById("notice"),
|
||||
serverState: document.getElementById("serverState"),
|
||||
score: document.getElementById("score"),
|
||||
downloadSpeed: document.getElementById("downloadSpeed"),
|
||||
uploadSpeed: document.getElementById("uploadSpeed"),
|
||||
latencyMetric: document.getElementById("latencyMetric"),
|
||||
connectionState: document.getElementById("connectionState"),
|
||||
bandwidthLimit: document.getElementById("bandwidthLimit"),
|
||||
sampleCount: document.getElementById("sampleCount"),
|
||||
liveChart: document.getElementById("liveChart"),
|
||||
historyChart: document.getElementById("historyChart"),
|
||||
eventLog: document.getElementById("eventLog"),
|
||||
recordsBody: document.getElementById("recordsBody"),
|
||||
};
|
||||
|
||||
const DOWNLOAD_BYTES = 4 * 1024 * 1024;
|
||||
const UPLOAD_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
els.form.addEventListener("submit", startTest);
|
||||
els.refreshButton.addEventListener("click", loadRecords);
|
||||
window.addEventListener("resize", () => {
|
||||
drawLiveChart();
|
||||
drawHistoryChart();
|
||||
});
|
||||
|
||||
initializeDefaultTarget();
|
||||
connectHeartbeat();
|
||||
loadConfig();
|
||||
loadRecords();
|
||||
drawLiveChart();
|
||||
drawHistoryChart();
|
||||
|
||||
function initializeDefaultTarget() {
|
||||
const currentHost = window.location.host;
|
||||
if (currentHost && !value("target")) {
|
||||
document.getElementById("target").value = currentHost;
|
||||
}
|
||||
if ((window.location.protocol === "http:" || window.location.protocol === "https:") && !value("httpUrl")) {
|
||||
document.getElementById("httpUrl").value = `${window.location.origin}/api/config`;
|
||||
}
|
||||
}
|
||||
|
||||
async function startTest(event) {
|
||||
event.preventDefault();
|
||||
closeSource();
|
||||
state.samples = [];
|
||||
els.eventLog.textContent = "";
|
||||
setNotice("");
|
||||
setServerState("running", "运行中");
|
||||
els.startButton.disabled = true;
|
||||
updateLiveMetrics();
|
||||
drawLiveChart();
|
||||
|
||||
const payload = {
|
||||
target: value("target"),
|
||||
httpUrl: value("httpUrl"),
|
||||
durationSeconds: numberValue("durationSeconds"),
|
||||
timeoutMillis: numberValue("timeoutMillis"),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/tests", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (response.status === 409) {
|
||||
setServerState("busy", "忙碌");
|
||||
setNotice(body.error || "有其他用户正在测速,请稍等。", true);
|
||||
els.startButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || "创建测试失败");
|
||||
}
|
||||
await runBrowserSpeedTest(body, payload);
|
||||
} catch (error) {
|
||||
setServerState("error", "错误");
|
||||
setNotice(error.message, true);
|
||||
els.startButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runBrowserSpeedTest(session, request) {
|
||||
const startedAt = new Date();
|
||||
const durationMs = Math.max(1000, (request.durationSeconds || 30) * 1000);
|
||||
|
||||
setNotice("下载测速中...");
|
||||
await measureLatency(request.timeoutMillis);
|
||||
await runTransferPhase(durationMs, () => measureDownload(session.downloadUrl, request.timeoutMillis));
|
||||
|
||||
setNotice("上传测速中...");
|
||||
await measureLatency(request.timeoutMillis);
|
||||
await runTransferPhase(durationMs, () => measureUpload(session.uploadUrl, request.timeoutMillis));
|
||||
await measureLatency(request.timeoutMillis);
|
||||
|
||||
const finishedAt = new Date();
|
||||
const response = await fetch(session.completeUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
samples: state.samples,
|
||||
}),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || "保存测试记录失败");
|
||||
}
|
||||
|
||||
updateSummary(body.summary);
|
||||
setServerState("idle", "空闲");
|
||||
setNotice("浏览器上传/下载测速完成,记录已保存。");
|
||||
els.startButton.disabled = false;
|
||||
await loadRecords();
|
||||
}
|
||||
|
||||
async function runTransferPhase(durationMs, measure) {
|
||||
const deadline = performance.now() + durationMs;
|
||||
let samples = 0;
|
||||
while (performance.now() < deadline || samples === 0) {
|
||||
await measure();
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
|
||||
async function measureLatency(timeoutMillis) {
|
||||
const started = performance.now();
|
||||
try {
|
||||
const response = await fetchWithTimeout(`/api/config?cache=${Date.now()}`, { cache: "no-store" }, timeoutMillis || 3000);
|
||||
await response.arrayBuffer();
|
||||
recordSample({
|
||||
at: new Date().toISOString(),
|
||||
kind: "latency",
|
||||
success: response.ok,
|
||||
latencyMs: round(performance.now() - started),
|
||||
error: response.ok ? "" : response.statusText,
|
||||
});
|
||||
} catch (error) {
|
||||
recordSample({
|
||||
at: new Date().toISOString(),
|
||||
kind: "latency",
|
||||
success: false,
|
||||
latencyMs: round(performance.now() - started),
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function measureDownload(downloadUrl, timeoutMillis) {
|
||||
const url = `${downloadUrl}&bytes=${DOWNLOAD_BYTES}&cache=${Date.now()}`;
|
||||
const started = performance.now();
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, { cache: "no-store" }, transferTimeoutMillis(timeoutMillis));
|
||||
const data = await response.arrayBuffer();
|
||||
const elapsed = performance.now() - started;
|
||||
recordSample({
|
||||
at: new Date().toISOString(),
|
||||
kind: "download",
|
||||
success: response.ok,
|
||||
bytes: data.byteLength,
|
||||
mbps: mbps(data.byteLength, elapsed),
|
||||
latencyMs: round(elapsed),
|
||||
error: response.ok ? "" : response.statusText,
|
||||
});
|
||||
} catch (error) {
|
||||
recordSample({
|
||||
at: new Date().toISOString(),
|
||||
kind: "download",
|
||||
success: false,
|
||||
bytes: 0,
|
||||
mbps: 0,
|
||||
latencyMs: round(performance.now() - started),
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function measureUpload(uploadUrl, timeoutMillis) {
|
||||
const payload = new Uint8Array(UPLOAD_BYTES);
|
||||
const started = performance.now();
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${uploadUrl}&cache=${Date.now()}`, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
}, transferTimeoutMillis(timeoutMillis));
|
||||
await response.arrayBuffer();
|
||||
const elapsed = performance.now() - started;
|
||||
recordSample({
|
||||
at: new Date().toISOString(),
|
||||
kind: "upload",
|
||||
success: response.ok,
|
||||
bytes: payload.byteLength,
|
||||
mbps: mbps(payload.byteLength, elapsed),
|
||||
latencyMs: round(elapsed),
|
||||
error: response.ok ? "" : response.statusText,
|
||||
});
|
||||
} catch (error) {
|
||||
recordSample({
|
||||
at: new Date().toISOString(),
|
||||
kind: "upload",
|
||||
success: false,
|
||||
bytes: 0,
|
||||
mbps: 0,
|
||||
latencyMs: round(performance.now() - started),
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function recordSample(sample) {
|
||||
state.samples.push(sample);
|
||||
appendLog(sample);
|
||||
updateLiveMetrics();
|
||||
drawLiveChart();
|
||||
}
|
||||
|
||||
function subscribe(eventsUrl) {
|
||||
state.source = new EventSource(eventsUrl);
|
||||
state.source.addEventListener("sample", event => {
|
||||
const sample = JSON.parse(event.data);
|
||||
state.samples.push(sample);
|
||||
appendLog(sample);
|
||||
updateLiveMetrics();
|
||||
drawLiveChart();
|
||||
});
|
||||
state.source.addEventListener("summary", event => {
|
||||
const summary = JSON.parse(event.data);
|
||||
updateSummary(summary);
|
||||
setServerState("idle", "空闲");
|
||||
setNotice("测试完成,记录已保存。");
|
||||
els.startButton.disabled = false;
|
||||
closeSource();
|
||||
loadRecords();
|
||||
});
|
||||
state.source.addEventListener("error", event => {
|
||||
if (event.data) {
|
||||
const payload = JSON.parse(event.data);
|
||||
setNotice(payload.error || "测试流发生错误", true);
|
||||
}
|
||||
setServerState("error", "错误");
|
||||
els.startButton.disabled = false;
|
||||
closeSource();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
try {
|
||||
const response = await fetch("/api/records?limit=200");
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || "读取记录失败");
|
||||
}
|
||||
state.records = body.records || [];
|
||||
renderRecords();
|
||||
drawHistoryChart();
|
||||
} catch (error) {
|
||||
setNotice(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function connectHeartbeat() {
|
||||
if (!window.EventSource) {
|
||||
els.connectionState.textContent = "不支持";
|
||||
return;
|
||||
}
|
||||
const source = new EventSource("/api/health/events");
|
||||
source.addEventListener("open", () => {
|
||||
els.connectionState.textContent = "已连接";
|
||||
});
|
||||
source.addEventListener("heartbeat", event => {
|
||||
const payload = JSON.parse(event.data);
|
||||
els.connectionState.textContent = "已连接";
|
||||
if (payload.bandwidthLimitLabel) {
|
||||
els.bandwidthLimit.textContent = payload.bandwidthLimitLabel;
|
||||
}
|
||||
});
|
||||
source.addEventListener("error", () => {
|
||||
els.connectionState.textContent = "断开";
|
||||
});
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch("/api/config");
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || "读取配置失败");
|
||||
}
|
||||
els.bandwidthLimit.textContent = body.bandwidthLimitLabel || "不限速";
|
||||
} catch (error) {
|
||||
els.bandwidthLimit.textContent = "--";
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecords() {
|
||||
if (!state.records.length) {
|
||||
els.recordsBody.innerHTML = `<tr class="empty-row"><td colspan="9">暂无测试记录</td></tr>`;
|
||||
return;
|
||||
}
|
||||
els.recordsBody.innerHTML = state.records.map(record => {
|
||||
const summary = record.summary || {};
|
||||
const client = record.client || {};
|
||||
const startedAt = summary.startedAt || record.createdAt;
|
||||
return `
|
||||
<tr data-record-id="${escapeHtml(record.id || "")}">
|
||||
<td data-label="时间">${formatTime(startedAt)}</td>
|
||||
<td data-label="脱敏 IP">${escapeHtml(client.ip || "unknown")}</td>
|
||||
<td data-label="地区 / 运营商">${escapeHtml(locationText(client))}</td>
|
||||
<td data-label="目标">${escapeHtml(summary.target || "")}</td>
|
||||
<td data-label="评分" class="${scoreClass(summary.score)}">${formatNumber(summary.score)}</td>
|
||||
<td data-label="下载">${formatNumber(summary.avgDownloadMbps)} Mbps</td>
|
||||
<td data-label="上传">${formatNumber(summary.avgUploadMbps)} Mbps</td>
|
||||
<td data-label="延迟">${formatNumber(summary.avgLatencyMs)} ms</td>
|
||||
<td data-label="样本">${(record.samples || []).length}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function updateLiveMetrics() {
|
||||
const successes = state.samples.filter(sample => sample.success);
|
||||
const failures = state.samples.length - successes.length;
|
||||
const latencies = successes.filter(sample => sample.kind === "latency").map(sample => sample.latencyMs || 0);
|
||||
const downloads = successes.filter(sample => sample.kind === "download").map(sample => sample.mbps || 0);
|
||||
const uploads = successes.filter(sample => sample.kind === "upload").map(sample => sample.mbps || 0);
|
||||
const avg = average(latencies);
|
||||
const jitter = average(latencies.slice(1).map((value, index) => Math.abs(value - latencies[index])));
|
||||
const loss = state.samples.length ? failures / state.samples.length * 100 : 0;
|
||||
const score = state.samples.length ? Math.max(0, 100 - loss * 1.5 - Math.min(avg / 20, 20) - Math.min(jitter / 5, 15)) : 0;
|
||||
|
||||
els.score.textContent = state.samples.length ? formatNumber(score) : "--";
|
||||
els.downloadSpeed.textContent = downloads.length ? `${formatNumber(average(downloads))} Mbps` : "--";
|
||||
els.uploadSpeed.textContent = uploads.length ? `${formatNumber(average(uploads))} Mbps` : "--";
|
||||
els.latencyMetric.textContent = latencies.length ? `${formatNumber(avg)} ms` : "--";
|
||||
els.sampleCount.textContent = `${state.samples.length} 个样本`;
|
||||
}
|
||||
|
||||
function updateSummary(summary) {
|
||||
els.score.textContent = formatNumber(summary.score);
|
||||
els.downloadSpeed.textContent = `${formatNumber(summary.avgDownloadMbps)} Mbps`;
|
||||
els.uploadSpeed.textContent = `${formatNumber(summary.avgUploadMbps)} Mbps`;
|
||||
els.latencyMetric.textContent = `${formatNumber(summary.avgLatencyMs)} ms`;
|
||||
}
|
||||
|
||||
function drawLiveChart() {
|
||||
const points = state.samples
|
||||
.filter(sample => sample.success && typeof sample.mbps === "number" && sample.mbps > 0)
|
||||
.map(sample => ({ x: new Date(sample.at), y: sample.mbps }));
|
||||
drawLineChart(els.liveChart, points, {
|
||||
empty: "等待上传/下载样本",
|
||||
yLabel: "Mbps",
|
||||
line: "#2563eb",
|
||||
});
|
||||
}
|
||||
|
||||
function drawHistoryChart() {
|
||||
const points = state.records
|
||||
.map(record => {
|
||||
const summary = record.summary || {};
|
||||
return {
|
||||
x: new Date(summary.startedAt || record.createdAt),
|
||||
y: Number(summary.avgDownloadMbps || 0),
|
||||
score: Number(summary.score || 0),
|
||||
};
|
||||
})
|
||||
.filter(point => Number.isFinite(point.y) && point.x.toString() !== "Invalid Date")
|
||||
.sort((a, b) => a.x - b.x);
|
||||
drawLineChart(els.historyChart, points, {
|
||||
empty: "暂无历史记录",
|
||||
yLabel: "下载 Mbps",
|
||||
line: "#0f9f6e",
|
||||
showPeriods: true,
|
||||
});
|
||||
}
|
||||
|
||||
function drawLineChart(canvas, points, options) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.floor(width * scale);
|
||||
canvas.height = Math.floor(height * scale);
|
||||
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const pad = { left: 48, right: 18, top: 18, bottom: 36 };
|
||||
const plotWidth = width - pad.left - pad.right;
|
||||
const plotHeight = height - pad.top - pad.bottom;
|
||||
|
||||
ctx.fillStyle = "#fbfdff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
if (options.showPeriods) {
|
||||
drawPeriodBands(ctx, pad, plotWidth, plotHeight);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = "#d8e0ea";
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad.top + plotHeight * i / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, y);
|
||||
ctx.lineTo(width - pad.right, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (!points.length) {
|
||||
ctx.fillStyle = "#627084";
|
||||
ctx.font = "13px system-ui";
|
||||
ctx.fillText(options.empty, pad.left, pad.top + 24);
|
||||
return;
|
||||
}
|
||||
|
||||
const ys = points.map(point => point.y);
|
||||
const minY = Math.min(0, ...ys);
|
||||
const maxY = Math.max(...ys, 10);
|
||||
const spanY = maxY - minY || 1;
|
||||
const xMin = points[0].x.getTime();
|
||||
const xMax = points[points.length - 1].x.getTime();
|
||||
const spanX = xMax - xMin || 1;
|
||||
const px = point => pad.left + (point.x.getTime() - xMin) / spanX * plotWidth;
|
||||
const py = point => pad.top + plotHeight - (point.y - minY) / spanY * plotHeight;
|
||||
|
||||
ctx.strokeStyle = options.line;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
points.forEach((point, index) => {
|
||||
const x = px(point);
|
||||
const y = py(point);
|
||||
if (index === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = options.line;
|
||||
points.forEach(point => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(px(point), py(point), 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.fillStyle = "#627084";
|
||||
ctx.font = "12px system-ui";
|
||||
ctx.fillText(`${formatNumber(maxY)} ${options.yLabel}`, 8, pad.top + 4);
|
||||
ctx.fillText(`${formatNumber(minY)} ${options.yLabel}`, 8, pad.top + plotHeight);
|
||||
ctx.fillText(formatTime(points[0].x), pad.left, height - 12);
|
||||
ctx.fillText(formatTime(points[points.length - 1].x), Math.max(pad.left, width - pad.right - 120), height - 12);
|
||||
}
|
||||
|
||||
function drawPeriodBands(ctx, pad, plotWidth, plotHeight) {
|
||||
const labels = [
|
||||
{ text: "早上", color: "rgba(37, 99, 235, 0.06)" },
|
||||
{ text: "中午", color: "rgba(183, 107, 0, 0.08)" },
|
||||
{ text: "晚上", color: "rgba(15, 159, 110, 0.07)" },
|
||||
];
|
||||
labels.forEach((label, index) => {
|
||||
const x = pad.left + plotWidth * index / labels.length;
|
||||
const w = plotWidth / labels.length;
|
||||
ctx.fillStyle = label.color;
|
||||
ctx.fillRect(x, pad.top, w, plotHeight);
|
||||
ctx.fillStyle = "#627084";
|
||||
ctx.font = "12px system-ui";
|
||||
ctx.fillText(label.text, x + 8, pad.top + 18);
|
||||
});
|
||||
}
|
||||
|
||||
function appendLog(sample) {
|
||||
const value = sample.kind === "latency" ? `${formatNumber(sample.latencyMs)}ms` : `${formatNumber(sample.mbps)}Mbps`;
|
||||
const line = `${formatTime(sample.at)} ${sample.kind} ${sample.success ? "OK" : "FAIL"} ${value} ${sample.error || ""}`;
|
||||
const div = document.createElement("div");
|
||||
div.textContent = line;
|
||||
els.eventLog.prepend(div);
|
||||
while (els.eventLog.children.length > 30) {
|
||||
els.eventLog.removeChild(els.eventLog.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
function setServerState(type, label) {
|
||||
els.serverState.className = `state ${type}`;
|
||||
els.serverState.textContent = label;
|
||||
}
|
||||
|
||||
function setNotice(message, isError = false) {
|
||||
els.notice.textContent = message;
|
||||
els.notice.className = isError ? "notice error" : "notice";
|
||||
}
|
||||
|
||||
function closeSource() {
|
||||
if (state.source) {
|
||||
state.source.close();
|
||||
state.source = null;
|
||||
}
|
||||
}
|
||||
|
||||
function value(id) {
|
||||
return document.getElementById(id).value.trim();
|
||||
}
|
||||
|
||||
function numberValue(id) {
|
||||
return Number(document.getElementById(id).value);
|
||||
}
|
||||
|
||||
function average(values) {
|
||||
if (!values.length) return 0;
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function transferTimeoutMillis(timeoutMillis) {
|
||||
return Math.max(timeoutMillis || 3000, 30000);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options, timeoutMillis) {
|
||||
const controller = new AbortController();
|
||||
const timeoutID = window.setTimeout(() => controller.abort(), timeoutMillis);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
window.clearTimeout(timeoutID);
|
||||
}
|
||||
}
|
||||
|
||||
function mbps(bytes, elapsedMs) {
|
||||
if (!bytes || !elapsedMs) return 0;
|
||||
return round(bytes * 8 / elapsedMs / 1000);
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return "--";
|
||||
return number.toFixed(number >= 100 ? 0 : 1);
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return "--";
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (date.toString() === "Invalid Date") return "--";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function locationText(client) {
|
||||
const place = [client.country, client.region, client.city].filter(Boolean).join(" ") || "未知地区";
|
||||
const network = [client.isp || "未知运营商", client.asn].filter(Boolean).join(" / ");
|
||||
return [place, network].filter(Boolean).join(" | ") || "未知";
|
||||
}
|
||||
|
||||
function scoreClass(score) {
|
||||
const value = Number(score || 0);
|
||||
if (value >= 85) return "score-good";
|
||||
if (value >= 60) return "score-mid";
|
||||
return "score-bad";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NetStable 测速监控</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>NetStable</h1>
|
||||
<p>浏览器到服务器上传下载实时测速</p>
|
||||
</div>
|
||||
<div id="serverState" class="state idle">空闲</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel control-panel">
|
||||
<h2>开始测试</h2>
|
||||
<form id="testForm" class="form">
|
||||
<label>
|
||||
<span>目标 IP / 域名(可带端口)</span>
|
||||
<input id="target" name="target" autocomplete="off" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>延迟探测 URL</span>
|
||||
<input id="httpUrl" name="httpUrl" placeholder="可选,例如 https://example.com/">
|
||||
</label>
|
||||
<div class="grid-2">
|
||||
<label>
|
||||
<span>每阶段时长(秒)</span>
|
||||
<input id="durationSeconds" name="durationSeconds" type="number" min="1" max="600" value="30">
|
||||
</label>
|
||||
<label>
|
||||
<span>超时(毫秒)</span>
|
||||
<input id="timeoutMillis" name="timeoutMillis" type="number" min="100" max="30000" value="3000">
|
||||
</label>
|
||||
</div>
|
||||
<button id="startButton" type="submit">开始实时测试</button>
|
||||
</form>
|
||||
<p id="notice" class="notice"></p>
|
||||
</section>
|
||||
|
||||
<section class="metrics" aria-label="实时指标">
|
||||
<div class="metric">
|
||||
<span>稳定评分</span>
|
||||
<strong id="score">--</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>下载速度</span>
|
||||
<strong id="downloadSpeed">--</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>上传速度</span>
|
||||
<strong id="uploadSpeed">--</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>平均延迟</span>
|
||||
<strong id="latencyMetric">--</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>辅助连接</span>
|
||||
<strong id="connectionState">连接中</strong>
|
||||
</div>
|
||||
<div class="metric limit-metric">
|
||||
<span>理论最高限值</span>
|
||||
<strong id="bandwidthLimit">加载中</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
<div class="section-title">
|
||||
<h2>实时曲线</h2>
|
||||
<span id="sampleCount">0 个样本</span>
|
||||
</div>
|
||||
<canvas id="liveChart" width="900" height="300"></canvas>
|
||||
<div id="eventLog" class="event-log"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
<div class="section-title">
|
||||
<h2>历史时序图</h2>
|
||||
<span>按测试开始时间排序</span>
|
||||
</div>
|
||||
<canvas id="historyChart" width="900" height="300"></canvas>
|
||||
</section>
|
||||
|
||||
<section class="panel records-panel">
|
||||
<div class="section-title">
|
||||
<h2>所有测试记录</h2>
|
||||
<button id="refreshButton" type="button">刷新</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>脱敏 IP</th>
|
||||
<th>地区 / 运营商</th>
|
||||
<th>目标</th>
|
||||
<th>评分</th>
|
||||
<th>下载</th>
|
||||
<th>上传</th>
|
||||
<th>延迟</th>
|
||||
<th>样本</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recordsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,428 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f7fb;
|
||||
--panel: #ffffff;
|
||||
--ink: #172033;
|
||||
--muted: #627084;
|
||||
--line: #d8e0ea;
|
||||
--blue: #2563eb;
|
||||
--green: #0f9f6e;
|
||||
--red: #d14343;
|
||||
--amber: #b76b00;
|
||||
--shadow: 0 8px 24px rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 22px 28px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.topbar p,
|
||||
.section-title span,
|
||||
label span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.state {
|
||||
min-width: 88px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.state.idle {
|
||||
color: var(--green);
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.state.running {
|
||||
color: var(--blue);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.state.busy,
|
||||
.state.error {
|
||||
color: var(--red);
|
||||
background: #fff1f1;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.9fr) minmax(520px, 1.4fr);
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.metric {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.control-panel,
|
||||
.chart-panel,
|
||||
.records-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
background: #fbfdff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.2);
|
||||
border-color: var(--blue);
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 40px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
background: var(--blue);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#refreshButton {
|
||||
min-height: 34px;
|
||||
color: var(--blue);
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.notice {
|
||||
min-height: 22px;
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-self: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.limit-metric {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
margin-top: 12px;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.records-panel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 980px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 11px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
background: #f8fafc;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.score-good {
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.score-mid {
|
||||
color: var(--amber);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.score-bad {
|
||||
color: var(--red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
body {
|
||||
background: #eef3f8;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.state {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.layout {
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.control-panel,
|
||||
.chart-panel,
|
||||
.records-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
font-size: 21px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
max-height: 96px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
table,
|
||||
thead,
|
||||
tbody,
|
||||
tr,
|
||||
td {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tr {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
td {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(92px, 38%) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
td::before {
|
||||
content: attr(data-label);
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-row td {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-row td::before {
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
td {
|
||||
grid-template-columns: minmax(82px, 34%) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user