This commit is contained in:
henrygd
2026-04-26 17:19:15 -04:00
parent af49ebf2df
commit f830665984
8 changed files with 113 additions and 91 deletions

View File

@@ -31,7 +31,7 @@ import (
const ( const (
// probeRawRetention is the duration to keep individual samples for high-precision short-term requests // probeRawRetention is the duration to keep individual samples for high-precision short-term requests
probeRawRetention = 80 * time.Second probeRawRetention = 70 * time.Second
// probeMinuteBucketLen is the number of 1-minute buckets to keep (1 hour + 1 for partials) // probeMinuteBucketLen is the number of 1-minute buckets to keep (1 hour + 1 for partials)
probeMinuteBucketLen int32 = 61 probeMinuteBucketLen int32 = 61
) )
@@ -54,7 +54,7 @@ type probeTask struct {
// probeSample stores one probe attempt and its collection time. // probeSample stores one probe attempt and its collection time.
type probeSample struct { type probeSample struct {
responseMs float64 // -1 means loss responseUs int64 // -1 means loss
timestamp time.Time timestamp time.Time
} }
@@ -67,11 +67,11 @@ type probeBucket struct {
// probeAggregate accumulates successful response stats and total sample counts. // probeAggregate accumulates successful response stats and total sample counts.
type probeAggregate struct { type probeAggregate struct {
sumMs float64 sumUs int64
minMs float64 minUs int64
maxMs float64 maxUs int64
totalCount int totalCount int64
successCount int successCount int64
} }
func newProbeManager() *ProbeManager { func newProbeManager() *ProbeManager {
@@ -104,22 +104,22 @@ func newProbeTaskFromExisting(config probe.Config, existing *probeTask) *probeTa
// newProbeAggregate initializes an aggregate with an unset minimum value. // newProbeAggregate initializes an aggregate with an unset minimum value.
func newProbeAggregate() probeAggregate { func newProbeAggregate() probeAggregate {
return probeAggregate{minMs: math.MaxFloat64} return probeAggregate{minUs: math.MaxInt64}
} }
// addResponse folds a single probe sample into the aggregate. // addResponse folds a single probe sample into the aggregate.
func (agg *probeAggregate) addResponse(responseMs float64) { func (agg *probeAggregate) addResponse(responseUs int64) {
agg.totalCount++ agg.totalCount++
if responseMs < 0 { if responseUs < 0 {
return return
} }
agg.successCount++ agg.successCount++
agg.sumMs += responseMs agg.sumUs += responseUs
if responseMs < agg.minMs { if responseUs < agg.minUs {
agg.minMs = responseMs agg.minUs = responseUs
} }
if responseMs > agg.maxMs { if responseUs > agg.maxUs {
agg.maxMs = responseMs agg.maxUs = responseUs
} }
} }
@@ -130,15 +130,15 @@ func (agg *probeAggregate) addAggregate(other probeAggregate) {
} }
agg.totalCount += other.totalCount agg.totalCount += other.totalCount
agg.successCount += other.successCount agg.successCount += other.successCount
agg.sumMs += other.sumMs agg.sumUs += other.sumUs
if other.successCount == 0 { if other.successCount == 0 {
return return
} }
if agg.minMs == math.MaxFloat64 || other.minMs < agg.minMs { if agg.minUs == math.MaxInt64 || other.minUs < agg.minUs {
agg.minMs = other.minMs agg.minUs = other.minUs
} }
if other.maxMs > agg.maxMs { if other.maxUs > agg.maxUs {
agg.maxMs = other.maxMs agg.maxUs = other.maxUs
} }
} }
@@ -150,14 +150,14 @@ func (agg probeAggregate) hasData() bool {
// result converts the aggregate into the probe result slice format. // result converts the aggregate into the probe result slice format.
func (agg probeAggregate) result() probe.Result { func (agg probeAggregate) result() probe.Result {
avg := agg.avgResponse() avg := agg.avgResponse()
minMs := 0.0 minUs := 0.0
if agg.successCount > 0 { if agg.successCount > 0 {
minMs = math.Round(agg.minMs*100) / 100 minUs = float64(agg.minUs)
} }
return probe.Result{ return probe.Result{
avg, avg,
minMs, minUs,
math.Round(agg.maxMs*100) / 100, float64(agg.maxUs),
agg.lossPercentage(), agg.lossPercentage(),
} }
} }
@@ -167,7 +167,8 @@ func (agg probeAggregate) avgResponse() float64 {
if agg.successCount == 0 { if agg.successCount == 0 {
return 0 return 0
} }
return math.Round(agg.sumMs/float64(agg.successCount)*100) / 100 return float64(agg.sumUs / agg.successCount)
} }
// lossPercentage returns the rounded failure rate for the aggregate. // lossPercentage returns the rounded failure rate for the aggregate.
@@ -406,8 +407,8 @@ func (task *probeTask) resultLocked(duration time.Duration, now time.Time) (prob
return probe.Result{ return probe.Result{
result[0], result[0],
hourAvg, hourAvg,
math.Round(hourAgg.minMs*100) / 100, float64(hourAgg.minUs),
math.Round(hourAgg.maxMs*100) / 100, float64(hourAgg.maxUs),
hourLoss, hourLoss,
}, true }, true
} }
@@ -421,7 +422,7 @@ func aggregateSamplesSince(samples []probeSample, cutoff time.Time) probeAggrega
if sample.timestamp.Before(cutoff) { if sample.timestamp.Before(cutoff) {
continue continue
} }
agg.addResponse(sample.responseMs) agg.addResponse(sample.responseUs)
} }
return agg return agg
} }
@@ -467,27 +468,27 @@ func (task *probeTask) addSampleLocked(sample probeSample) {
bucket.filled = true bucket.filled = true
bucket.stats = newProbeAggregate() bucket.stats = newProbeAggregate()
} }
bucket.stats.addResponse(sample.responseMs) bucket.stats.addResponse(sample.responseUs)
} }
// executeProbe runs the configured probe and records the sample. // executeProbe runs the configured probe and records the sample.
func (pm *ProbeManager) executeProbe(task *probeTask) { func (pm *ProbeManager) executeProbe(task *probeTask) {
var responseMs float64 var responseUs int64
switch task.config.Protocol { switch task.config.Protocol {
case "icmp": case "icmp":
responseMs = probeICMP(task.config.Target) responseUs = probeICMP(task.config.Target)
case "tcp": case "tcp":
responseMs = probeTCP(task.config.Target, task.config.Port) responseUs = probeTCP(task.config.Target, task.config.Port)
case "http": case "http":
responseMs = probeHTTP(pm.httpClient, task.config.Target) responseUs = probeHTTP(pm.httpClient, task.config.Target)
default: default:
slog.Warn("unknown probe protocol", "protocol", task.config.Protocol) slog.Warn("unknown probe protocol", "protocol", task.config.Protocol)
return return
} }
sample := probeSample{ sample := probeSample{
responseMs: responseMs, responseUs: responseUs,
timestamp: time.Now(), timestamp: time.Now(),
} }
@@ -498,7 +499,7 @@ func (pm *ProbeManager) executeProbe(task *probeTask) {
// probeTCP measures pure TCP handshake response (excluding DNS resolution). // probeTCP measures pure TCP handshake response (excluding DNS resolution).
// Returns -1 on failure. // Returns -1 on failure.
func probeTCP(target string, port uint16) float64 { func probeTCP(target string, port uint16) int64 {
// Resolve DNS first, outside the timing window // Resolve DNS first, outside the timing window
ips, err := net.LookupHost(target) ips, err := net.LookupHost(target)
if err != nil || len(ips) == 0 { if err != nil || len(ips) == 0 {
@@ -513,11 +514,11 @@ func probeTCP(target string, port uint16) float64 {
return -1 return -1
} }
conn.Close() conn.Close()
return float64(time.Since(start).Microseconds()) / 1000.0 return time.Since(start).Microseconds()
} }
// probeHTTP measures HTTP GET request response. Returns -1 on failure. // probeHTTP measures HTTP GET request response in microseconds. Returns -1 on failure.
func probeHTTP(client *http.Client, url string) float64 { func probeHTTP(client *http.Client, url string) int64 {
if client == nil { if client == nil {
client = http.DefaultClient client = http.DefaultClient
} }
@@ -530,5 +531,5 @@ func probeHTTP(client *http.Client, url string) float64 {
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return -1 return -1
} }
return float64(time.Since(start).Microseconds()) / 1000.0 return time.Since(start).Microseconds()
} }

View File

@@ -1,6 +1,7 @@
package agent package agent
import ( import (
"math"
"net" "net"
"os" "os"
"os/exec" "os/exec"
@@ -75,8 +76,8 @@ var (
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket, // Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
// unprivileged datagram, or exec fallback) is detected once per address // unprivileged datagram, or exec fallback) is detected once per address
// family and cached for subsequent probes. // family and cached for subsequent probes.
// Returns response in milliseconds, or -1 on failure. // Returns response in microseconds, or -1 on failure.
func probeICMP(target string) float64 { func probeICMP(target string) int64 {
family, ip := resolveICMPTarget(target) family, ip := resolveICMPTarget(target)
if family == nil { if family == nil {
return -1 return -1
@@ -150,7 +151,7 @@ func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string)
} }
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package. // probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 { func probeICMPNative(network string, family *icmpFamily, dst net.Addr) int64 {
conn, err := icmp.ListenPacket(network, family.listenAddr) conn, err := icmp.ListenPacket(network, family.listenAddr)
if err != nil { if err != nil {
return -1 return -1
@@ -194,14 +195,14 @@ func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
} }
if reply.Type == family.replyType { if reply.Type == family.replyType {
return float64(time.Since(start).Microseconds()) / 1000.0 return time.Since(start).Microseconds()
} }
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading // Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
} }
} }
// probeICMPExec falls back to the system ping command. Returns -1 on failure. // probeICMPExec falls back to the system ping command. Returns -1 on failure.
func probeICMPExec(target string, isIPv6 bool) float64 { func probeICMPExec(target string, isIPv6 bool) int64 {
var cmd *exec.Cmd var cmd *exec.Cmd
switch runtime.GOOS { switch runtime.GOOS {
case "windows": case "windows":
@@ -230,13 +231,13 @@ func probeICMPExec(target string, isIPv6 bool) float64 {
matches := pingTimeRegex.FindSubmatch(output) matches := pingTimeRegex.FindSubmatch(output)
if len(matches) >= 2 { if len(matches) >= 2 {
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil { if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
return ms return int64(math.Round(ms * 1000))
} }
} }
// Fallback: use wall clock time if ping succeeded but parsing failed // Fallback: use wall clock time if ping succeeded but parsing failed
if err == nil { if err == nil {
return float64(time.Since(start).Microseconds()) / 1000.0 return time.Since(start).Microseconds()
} }
return -1 return -1
} }

View File

@@ -16,9 +16,9 @@ func TestProbeTaskAggregateLockedUsesRawSamplesForShortWindows(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC) now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
task := &probeTask{} task := &probeTask{}
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-90 * time.Second)}) task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-90 * time.Second)})
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now.Add(-30 * time.Second)}) task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-30 * time.Second)})
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-10 * time.Second)}) task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
agg := task.aggregateLocked(time.Minute, now) agg := task.aggregateLocked(time.Minute, now)
require.True(t, agg.hasData()) require.True(t, agg.hasData())
@@ -34,11 +34,11 @@ func TestProbeTaskAggregateLockedUsesMinuteBucketsForLongWindows(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC) now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC)
task := &probeTask{} task := &probeTask{}
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-11 * time.Minute)}) task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-11 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now.Add(-9 * time.Minute)}) task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: 40, timestamp: now.Add(-5 * time.Minute)}) task.addSampleLocked(probeSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-90 * time.Second)}) task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-90 * time.Second)})
task.addSampleLocked(probeSample{responseMs: 30, timestamp: now.Add(-30 * time.Second)}) task.addSampleLocked(probeSample{responseUs: 30, timestamp: now.Add(-30 * time.Second)})
agg := task.aggregateLocked(10*time.Minute, now) agg := task.aggregateLocked(10*time.Minute, now)
require.True(t, agg.hasData()) require.True(t, agg.hasData())
@@ -54,11 +54,11 @@ func TestProbeTaskAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC) now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
task := &probeTask{} task := &probeTask{}
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-10 * time.Minute)}) task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-10 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now}) task.addSampleLocked(probeSample{responseUs: 20, timestamp: now})
require.Len(t, task.samples, 1) require.Len(t, task.samples, 1)
assert.Equal(t, 20.0, task.samples[0].responseMs) assert.Equal(t, int64(20), task.samples[0].responseUs)
agg := task.aggregateLocked(10*time.Minute, now) agg := task.aggregateLocked(10*time.Minute, now)
require.True(t, agg.hasData()) require.True(t, agg.hasData())
@@ -73,11 +73,11 @@ func TestProbeTaskAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing
func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) { func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
task := &probeTask{config: probe.Config{ID: "probe-1"}} task := &probeTask{config: probe.Config{ID: "probe-1"}}
task.addSampleLocked(probeSample{responseMs: 10, timestamp: now.Add(-30 * time.Minute)}) task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-30 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: 20, timestamp: now.Add(-9 * time.Minute)}) task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: 40, timestamp: now.Add(-5 * time.Minute)}) task.addSampleLocked(probeSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-90 * time.Second)}) task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-90 * time.Second)})
task.addSampleLocked(probeSample{responseMs: 30, timestamp: now.Add(-30 * time.Second)}) task.addSampleLocked(probeSample{responseUs: 30, timestamp: now.Add(-30 * time.Second)})
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}} pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
@@ -95,8 +95,8 @@ func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) { func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
now := time.Now().UTC() now := time.Now().UTC()
task := &probeTask{config: probe.Config{ID: "probe-1"}} task := &probeTask{config: probe.Config{ID: "probe-1"}}
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-30 * time.Second)}) task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
task.addSampleLocked(probeSample{responseMs: -1, timestamp: now.Add(-10 * time.Second)}) task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}} pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
@@ -222,8 +222,8 @@ func TestProbeManagerUpsertProbeKeepsHistoryWhenOnlyIntervalChanges(t *testing.T
now := time.Now().UTC() now := time.Now().UTC()
existingTask := &probeTask{config: originalCfg, cancel: make(chan struct{})} existingTask := &probeTask{config: originalCfg, cancel: make(chan struct{})}
existingTask.addSampleLocked(probeSample{responseMs: 12, timestamp: now.Add(-50 * time.Minute)}) existingTask.addSampleLocked(probeSample{responseUs: 12, timestamp: now.Add(-50 * time.Minute)})
existingTask.addSampleLocked(probeSample{responseMs: 24, timestamp: now.Add(-30 * time.Second)}) existingTask.addSampleLocked(probeSample{responseUs: 24, timestamp: now.Add(-30 * time.Second)})
pm := &ProbeManager{ pm := &ProbeManager{
probes: map[string]*probeTask{originalCfg.ID: existingTask}, probes: map[string]*probeTask{originalCfg.ID: existingTask},
@@ -243,7 +243,7 @@ func TestProbeManagerUpsertProbeKeepsHistoryWhenOnlyIntervalChanges(t *testing.T
updatedTask.mu.Lock() updatedTask.mu.Lock()
defer updatedTask.mu.Unlock() defer updatedTask.mu.Unlock()
require.Len(t, updatedTask.samples, 1) require.Len(t, updatedTask.samples, 1)
assert.Equal(t, 24.0, updatedTask.samples[0].responseMs) assert.Equal(t, int64(24), updatedTask.samples[0].responseUs)
agg := updatedTask.aggregateLocked(time.Hour, now) agg := updatedTask.aggregateLocked(time.Hour, now)
require.True(t, agg.hasData()) require.True(t, agg.hasData())
@@ -296,8 +296,8 @@ func TestProbeHTTP(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
responseMs := probeHTTP(server.Client(), server.URL) responseUs := probeHTTP(server.Client(), server.URL)
assert.GreaterOrEqual(t, responseMs, 0.0) assert.GreaterOrEqual(t, responseUs, int64(0))
}) })
t.Run("server error", func(t *testing.T) { t.Run("server error", func(t *testing.T) {
@@ -306,7 +306,7 @@ func TestProbeHTTP(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
assert.Equal(t, -1.0, probeHTTP(server.Client(), server.URL)) assert.Equal(t, int64(-1), probeHTTP(server.Client(), server.URL))
}) })
} }
@@ -326,8 +326,8 @@ func TestProbeTCP(t *testing.T) {
}() }()
port := uint16(listener.Addr().(*net.TCPAddr).Port) port := uint16(listener.Addr().(*net.TCPAddr).Port)
responseMs := probeTCP("127.0.0.1", port) responseUs := probeTCP("127.0.0.1", port)
assert.GreaterOrEqual(t, responseMs, 0.0) assert.GreaterOrEqual(t, responseUs, int64(0))
<-accepted <-accepted
}) })
@@ -338,6 +338,6 @@ func TestProbeTCP(t *testing.T) {
port := uint16(listener.Addr().(*net.TCPAddr).Port) port := uint16(listener.Addr().(*net.TCPAddr).Port)
require.NoError(t, listener.Close()) require.NoError(t, listener.Close())
assert.Equal(t, -1.0, probeTCP("127.0.0.1", port)) assert.Equal(t, int64(-1), probeTCP("127.0.0.1", port))
}) })
} }

View File

@@ -36,13 +36,13 @@ type SyncResponse struct {
// Result holds aggregated probe results for a single target. // Result holds aggregated probe results for a single target.
// //
// 0: avg response in ms // 0: avg response in microseconds
// //
// 1: average response over the last hour in ms // 1: average response over the last hour in microseconds
// //
// 2: min response over the last hour in ms // 2: min response over the last hour in microseconds
// //
// 3: max response over the last hour in ms // 3: max response over the last hour in microseconds
// //
// 4: packet loss percentage over the last hour (0-100) // 4: packet loss percentage over the last hour (0-100)
type Result []float64 type Result []float64

View File

@@ -1,6 +1,6 @@
import type { CellContext, Column, ColumnDef } from "@tanstack/react-table" import type { CellContext, Column, ColumnDef } from "@tanstack/react-table"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn, decimalString, hourWithSeconds } from "@/lib/utils" import { cn, formatMicroseconds, hourWithSeconds } from "@/lib/utils"
import { import {
GlobeIcon, GlobeIcon,
TimerIcon, TimerIcon,
@@ -290,9 +290,9 @@ export function getProbeColumns(
} }
const responseTimeThresholds = { const responseTimeThresholds = {
http: { warning: 800, critical: 3000 }, http: { warning: 800_000, critical: 3_000_000 },
tcp: { warning: 500, critical: 2000 }, tcp: { warning: 500_000, critical: 2_000_000 },
icmp: { warning: 100, critical: 500 }, icmp: { warning: 100_000, critical: 500_000 },
} }
function responseTimeCell(cell: CellContext<NetworkProbeRecord, unknown>) { function responseTimeCell(cell: CellContext<NetworkProbeRecord, unknown>) {
@@ -317,7 +317,7 @@ function responseTimeCell(cell: CellContext<NetworkProbeRecord, unknown>) {
return ( return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center"> <span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", color)} /> <span className={cn("shrink-0 size-2 rounded-full", color)} />
{decimalString(responseTime, responseTime < 100 ? 2 : 1).toLocaleString()}ms {formatMicroseconds(responseTime)}
</span> </span>
) )
} }

View File

@@ -1,6 +1,6 @@
import LineChartDefault from "@/components/charts/line-chart" import LineChartDefault from "@/components/charts/line-chart"
import type { DataPoint } from "@/components/charts/line-chart" import type { DataPoint } from "@/components/charts/line-chart"
import { toFixedFloat, decimalString } from "@/lib/utils" import { decimalString, formatMicroseconds, toFixedFloat } from "@/lib/utils"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import { ChartCard, FilterBar } from "../chart-card" import { ChartCard, FilterBar } from "../chart-card"
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types" import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
@@ -116,13 +116,13 @@ export function ResponseChart({ probeStats, grid, probes, chartData, empty }: Pr
empty={empty} empty={empty}
valueIndex={0} valueIndex={0}
title={t`Response`} title={t`Response`}
description={t`Average response time (ms)`} description={t`Average response time`}
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`} tickFormatter={(value) => formatMicroseconds(value, false)}
contentFormatter={({ value }) => { contentFormatter={({ value }) => {
if (typeof value !== "number") { if (typeof value !== "number") {
return value return value
} }
return `${decimalString(value, 2)} ms` return formatMicroseconds(value)
}} }}
/> />
) )

View File

@@ -199,6 +199,26 @@ export function decimalString(num: number, digits = 2) {
return formatter.format(num) return formatter.format(num)
} }
export function formatMicroseconds(microseconds: number, showDigits = true): string {
if (!Number.isFinite(microseconds)) {
return "-"
}
if (microseconds < 1000) {
return `${microseconds}μs`
}
if (microseconds < 1_000_000) {
const milliseconds = microseconds / 1000
const digits = milliseconds >= 10 ? 1 : 2
return `${decimalString(milliseconds, showDigits ? digits : 0)}ms`
}
const seconds = microseconds / 1_000_000
const digits = seconds >= 10 ? 1 : 2
return `${decimalString(seconds, showDigits ? digits : 0)}s`
}
/** Get value from local or session storage */ /** Get value from local or session storage */
function getStorageValue(key: string, defaultValue: unknown, storageInterface: Storage = localStorage) { function getStorageValue(key: string, defaultValue: unknown, storageInterface: Storage = localStorage) {
const saved = storageInterface?.getItem(key) const saved = storageInterface?.getItem(key)

View File

@@ -563,15 +563,15 @@ export interface NetworkProbeRecord {
} }
/** /**
* 0: avg 1 minute response in ms * 0: avg 1 minute response in microseconds
* *
* 1: avg response over 1 hour in ms * 1: avg response over 1 hour in microseconds
* *
* 2: min response over the last hour in ms * 2: min response over the last hour in microseconds
* *
* 3: max response over the last hour in ms * 3: max response over the last hour in microseconds
* *
* 4: packet loss in % * 4: packet loss over 1 hour in %
*/ */
type ProbeResult = number[] type ProbeResult = number[]