The browser and CLI flows both use download/upload/complete; the
/api/tests/{id}/events endpoint and its Runner plumbing were dead code
that could bypass the single-active-test lock via expireActiveLocked
promoting the queue while an SSE stream was still running.
Remove the endpoint, Runner option, DefaultRunner, probe.Run engine and
the frontend subscribe/closeSource leftovers. Add a regression test
asserting the endpoint returns 404 and create responses omit eventsUrl.
220 lines
5.4 KiB
Go
220 lines
5.4 KiB
Go
package probe
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"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"`
|
|
}
|
|
|
|
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 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 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
|
|
}
|