Files
Test User 4d8a295504
ci / test (push) Successful in 10s
ci / release-artifacts (push) Skipped
refactor: remove legacy server-driven SSE test path
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.
2026-09-22 14:25:30 +08:00

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
}