From 118ac7d9875a611dba004600971fa65351530522 Mon Sep 17 00:00:00 2001 From: Ehco Date: Tue, 5 May 2026 14:04:58 +0800 Subject: [PATCH 1/2] metrics: drop node_exporter + self-scrape pipeline (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * metrics: drop node_exporter + self-scrape pipeline Node stats are now sampled directly via gopsutil/v4 in a tiny internal/cmgr/sampler package; rule stats stay backed by the existing prometheus client_golang collectors but read in-process via DefaultGatherer.Gather() instead of an HTTP self-scrape + expfmt parse. The xray bandwidth recorder, which used to GET /metrics for node_network_*_bytes_total, also moves to gopsutil. Removed: - pkg/metric_reader/ (HTTP scraper + expfmt parser) - prometheus/node_exporter dep + transitive baggage (kingpin, btrfs, qdisc, dbus, ...) - internal/metrics/{node_linux,node_darwin,log_level}.go - cmgr.Config.{MetricsURL,ApiToken}; relay no longer plumbs them - config.GetMetricURL Added: - github.com/shirou/gopsutil/v4 - internal/cmgr/sampler with NodeSampler (gopsutil) and RuleSampler (DefaultGatherer.Gather) Persisted NodeMetrics / RuleMetrics shapes are unchanged, so cmgr/ms/handler.go and the dashboard contracts keep working. Co-Authored-By: Claude Opus 4.7 (1M context) * metrics: rip out per-rule metrics feature pending rewrite The current rule-metrics implementation (nested 7-map RuleMetrics, ping- keyed iteration that silently drops rows when EnablePing=false, parallel maps that immediately get unpacked again at INSERT time) is too crufty to keep alive. Removing it from this PR — node sampling stays — and rebuilding from scratch on top of a flat row shape is the plan. Removed (backend): - internal/cmgr/sampler/rule.go (RuleSampler + map nesting) - sampler.RuleMetrics, sampler.PingMetric - ms.AddRuleMetric, ms.QueryRuleMetric and the rule_metrics table (DROP TABLE IF EXISTS in initDB so upgraded nodes don't carry stale pages) - ms.ruleRows, MaintenanceResult.RuleDeleted, DBHealth.RuleMetricsRows / LastRuleWriteTs - /api/v1/rule_metrics/ route + GetRuleMetrics handler - cmgr.QueryRuleMetrics interface entry Removed (frontend): - api.ruleMetrics, RuleMetric / QueryRuleMetricsResp types - DBHealth.rule_metrics_rows / last_rule_write_ts, MaintenanceResult .rule_deleted - Rules.tsx history sparkline + tcp xfer / conns / ping columns; reduced to label / listen / type / remote / probe (static config view) - Settings storage card's rule_metrics + last rule write rows Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- go.mod | 41 ++--- go.sum | 93 ++--------- internal/cmgr/cmgr.go | 11 +- internal/cmgr/config.go | 11 +- internal/cmgr/ms/handler.go | 129 +-------------- internal/cmgr/ms/health.go | 29 +--- internal/cmgr/ms/health_test.go | 14 +- internal/cmgr/ms/ms.go | 65 +++----- internal/cmgr/sampler/node.go | 88 ++++++++++ internal/cmgr/sampler/types.go | 24 +++ internal/cmgr/syncer.go | 29 ++-- internal/config/config.go | 9 -- internal/metrics/log_level.go | 21 --- internal/metrics/node_darwin.go | 36 ----- internal/metrics/node_linux.go | 33 ---- internal/relay/server.go | 7 +- internal/web/handler_api.go | 24 --- internal/web/server.go | 4 - internal/web/webui/src/api/client.ts | 15 -- internal/web/webui/src/api/types.ts | 21 --- internal/web/webui/src/pages/Rules.tsx | 136 +--------------- internal/web/webui/src/pages/Settings.tsx | 17 +- pkg/metric_reader/node.go | 185 ---------------------- pkg/metric_reader/reader.go | 84 ---------- pkg/metric_reader/rule.go | 146 ----------------- pkg/metric_reader/utils.go | 46 ------ pkg/xray/bandwidth_recorder.go | 87 +++------- pkg/xray/server.go | 2 +- pkg/xray/user.go | 47 +++--- 29 files changed, 245 insertions(+), 1209 deletions(-) create mode 100644 internal/cmgr/sampler/node.go create mode 100644 internal/cmgr/sampler/types.go delete mode 100644 internal/metrics/log_level.go delete mode 100644 internal/metrics/node_darwin.go delete mode 100644 internal/metrics/node_linux.go delete mode 100644 pkg/metric_reader/node.go delete mode 100644 pkg/metric_reader/reader.go delete mode 100644 pkg/metric_reader/rule.go delete mode 100644 pkg/metric_reader/utils.go diff --git a/go.mod b/go.mod index 1709ec57b..8897da701 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/Ehco1996/ehco go 1.26.1 require ( - github.com/alecthomas/kingpin/v2 v2.4.0 github.com/getsentry/sentry-go v0.43.0 github.com/go-ping/ping v1.2.0 github.com/gobwas/ws v1.4.0 @@ -12,103 +11,83 @@ require ( github.com/labstack/echo/v4 v4.15.1 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/prometheus/common v0.67.5 - github.com/prometheus/node_exporter v1.10.2 + github.com/shirou/gopsutil/v4 v4.26.4 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 github.com/xtls/xray-core v1.260206.0 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.27.1 golang.org/x/mod v0.34.0 + golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 golang.org/x/time v0.15.0 - google.golang.org/grpc v1.79.2 modernc.org/sqlite v1.46.1 ) require ( - cyphar.com/go-pathrs v0.2.4 // indirect - github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 // indirect - github.com/beevik/ntp v1.5.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect - github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55 // indirect - github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ema/qdisc v1.0.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-envparse v0.1.0 // indirect - github.com/hodgesds/perf-utils v0.7.0 // indirect - github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973 // indirect - github.com/jsimonetti/rtnetlink/v2 v2.2.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/labstack/gommon v0.4.2 // indirect - github.com/lufia/iostat v1.2.1 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-xmlrpc v0.0.3 // indirect - github.com/mdlayher/ethtool v0.5.1 // indirect - github.com/mdlayher/genetlink v1.3.2 // indirect - github.com/mdlayher/netlink v1.9.0 // indirect - github.com/mdlayher/socket v0.5.1 // indirect - github.com/mdlayher/wifi v0.7.2 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/opencontainers/selinux v1.13.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pires/go-proxyproto v0.11.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus-community/go-runit v0.1.0 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/refraction-networking/utls v1.8.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/safchain/ethtool v0.7.0 // indirect github.com/sagernet/sing v0.8.0 // indirect github.com/sagernet/sing-shadowsocks v0.2.9 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect - github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect + google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect - howett.net/plist v1.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index ed3bacb72..4b608fd13 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,21 @@ -cyphar.com/go-pathrs v0.2.4 h1:iD/mge36swa1UFKdINkr1Frkpp6wZsy3YYEildj9cLY= -cyphar.com/go-pathrs v0.2.4/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= -github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= -github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= -github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= -github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178 h1:bSq8n+gX4oO/qnM3MKf4kroW75n+phO9Qp6nigJKZ1E= github.com/apernet/quic-go v0.57.2-0.20260111184307-eec823306178/go.mod h1:N1WIjPphkqs4efXWuyDNQ6OjjIK04vM3h+bEgwV+eVU= -github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4= -github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cilium/ebpf v0.20.0 h1:atwWj9d3NffHyPZzVlx3hmw1on5CLe9eljR8VuHTwhM= -github.com/cilium/ebpf v0.20.0/go.mod h1:pzLjFymM+uZPLk/IXZUL63xdx5VXEo+enTzxkZXdycw= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= -github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55 h1:VAnGuI8RNnP8vHqCn8X1O63TexAv+QjMqffBdkLbYKU= -github.com/dennwc/btrfs v0.0.0-20260222081608-edfb8b9e4f55/go.mod h1:Kn6RQo4OP1ZEoLB3uldDJabFcf72VgDRInxEqLEo8OE= -github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068 h1:K71w/n/Y74EQsKo91511t7TK35YRPrk9G+2anKYNPXk= -github.com/dennwc/ioctl v1.0.1-0.20181021180353-017804252068/go.mod h1:ellh2YB5ldny99SBU/VX7Nq0xiZbHphf1DrtHxxjMk0= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ema/qdisc v1.0.0 h1:EHLG08FVRbWLg8uRICa3xzC9Zm0m7HyMHfXobWFnXYg= -github.com/ema/qdisc v1.0.0/go.mod h1:FhIc0fLYi7f+lK5maMsesDqwYojIOh3VfRs8EVd5YJQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= @@ -48,6 +28,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ping/ping v1.2.0 h1:vsJ8slZBZAXNCK4dPcI2PEE9eM9n9RbXbGouVQ/Y4yQ= github.com/go-ping/ping v1.2.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= @@ -56,14 +38,13 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -75,21 +56,12 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY= -github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hodgesds/perf-utils v0.7.0 h1:7KlHGMuig4FRH5fNw68PV6xLmgTe7jKs9hgAcEAbioU= -github.com/hodgesds/perf-utils v0.7.0/go.mod h1:LAklqfDadNKpkxoAJNHpD5tkY0rkZEVdnCEWN5k4QJY= -github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973 h1:hk4LPqXIY/c9XzRbe7dA6qQxaT6Axcbny0L/G5a4owQ= -github.com/illumos/go-kstat v0.0.0-20210513183136-173c9b0a9973/go.mod h1:PoK3ejP3LJkGTzKqRlpvCIFas3ncU02v8zzWDW+g0FY= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jsimonetti/rtnetlink/v2 v2.2.0 h1:/KfZ310gOAFrXXol5VwnFEt+ucldD/0dsSRZwpHCP9w= -github.com/jsimonetti/rtnetlink/v2 v2.2.0/go.mod h1:lbjDHxC+5RJ08lzPeA90Ls2pEoId3F08MoEMlhfHxeI= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -106,32 +78,18 @@ github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2ln github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/lufia/iostat v1.2.1 h1:tnCdZBIglgxD47RyD55kfWQcJMGzO+1QBziSQfesf2k= -github.com/lufia/iostat v1.2.1/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-xmlrpc v0.0.3 h1:Y6WEMLEsqs3RviBrAa1/7qmbGB7DVD3brZIbqMbQdGY= -github.com/mattn/go-xmlrpc v0.0.3/go.mod h1:mqc2dz7tP5x5BKlCahN/n+hs7OSZKJkS9JsHNBRlrxA= -github.com/mdlayher/ethtool v0.5.1 h1:U4GThY6WgNJUJsMrUzBmoOTdQHFWxFPTHTeNnn3GCvU= -github.com/mdlayher/ethtool v0.5.1/go.mod h1:Pz39PaAy96Ea1SrCvEO/pPEAeULvRJjO6zspuEMhJy4= -github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= -github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= -github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= -github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= -github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= -github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/mdlayher/wifi v0.7.2 h1:5yBq4nTm2HIYarKpJHrHU8q2BuxlX/BEfPa8kVKeOYU= -github.com/mdlayher/wifi v0.7.2/go.mod h1:zJM6S0QpUxpUgf915rgAQHE4/e1YzRRkhK3M26NPakI= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= -github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -140,21 +98,16 @@ github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG6 github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus-community/go-runit v0.1.0 h1:uTWEj/Fn2RoLdfg/etSqwzgYNOYPrARx1BHUN052tGA= -github.com/prometheus-community/go-runit v0.1.0/go.mod h1:AvJ9Jo3gAFu2lbM4+qfjdpq30FfiLDJZKbQ015u08IQ= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/node_exporter v1.10.2 h1:H88cUFLuB8Jn/u2U3M4D5KYnae07LIm+0ZTcgoKEK54= -github.com/prometheus/node_exporter v1.10.2/go.mod h1:F9EKoxCWmKgzJHBfL1EKEvxaGWyahuKZpxArLSI70lA= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -167,25 +120,18 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/safchain/ethtool v0.7.0 h1:rlJzfDetsVvT61uz8x1YIcFn12akMfuPulHtZjtb7Is= -github.com/safchain/ethtool v0.7.0/go.mod h1:MenQKEjXdfkjD3mp2QdCk8B/hwvkrlOTm/FD4gTpFxQ= github.com/sagernet/sing v0.8.0 h1:OwLEwbcYfZHvu4olZVljxxC1XRicBqJ1HfiFr6F2WEE= github.com/sagernet/sing v0.8.0/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM= github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8= -github.com/siebenmann/go-kstat v0.0.0-20210513183136-173c9b0a9973 h1:GfSdC6wKfTGcgCS7BtzF5694Amne1pGCSTY252WhlEY= -github.com/siebenmann/go-kstat v0.0.0-20210513183136-173c9b0a9973/go.mod h1:G81aIFAMS9ECrwBYR9YxhlPjWgrItd+Kje78O6+uqm8= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= +github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -196,8 +142,6 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= -github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= -github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xtls/reality v0.0.0-20251116175510-cd53f7d50237 h1:UXjrmniKlY+ZbIqpN91lejB3pszQQQRVu1vqH/p/aGM= @@ -206,6 +150,8 @@ github.com/xtls/xray-core v1.260206.0 h1:gY8IV6u76CW93txL9QmacgZ0Udxr2Q3e9qUxXAh github.com/xtls/xray-core v1.260206.0/go.mod h1:GyFIgVGRJkt3eyV/NMcdxOKXcJPqGGpyupHzy16uJhU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -218,14 +164,12 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= @@ -246,10 +190,10 @@ golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20211031064116-611d5d643895/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -264,6 +208,7 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= @@ -279,17 +224,13 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= -howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= -howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/internal/cmgr/cmgr.go b/internal/cmgr/cmgr.go index 1eb6a9678..e705b0eb6 100644 --- a/internal/cmgr/cmgr.go +++ b/internal/cmgr/cmgr.go @@ -10,8 +10,8 @@ import ( "time" "github.com/Ehco1996/ehco/internal/cmgr/ms" + "github.com/Ehco1996/ehco/internal/cmgr/sampler" "github.com/Ehco1996/ehco/internal/conn" - "github.com/Ehco1996/ehco/pkg/metric_reader" "go.uber.org/zap" ) @@ -41,7 +41,6 @@ type Cmgr interface { // Metrics related QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq) (*ms.QueryNodeMetricsResp, error) - QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq) (*ms.QueryRuleMetricsResp, error) // Storage health & maintenance. Each call surfaces the local // SQLite store; on builds without metrics enabled, the underlying @@ -67,7 +66,7 @@ type cmgrImpl struct { closedConnectionsMap map[string][]conn.RelayConn ms *ms.MetricsStore - mr metric_reader.Reader + ns *sampler.NodeSampler } func NewCmgr(cfg *Config) (Cmgr, error) { @@ -78,7 +77,7 @@ func NewCmgr(cfg *Config) (Cmgr, error) { closedConnectionsMap: make(map[string][]conn.RelayConn), } if cfg.NeedMetrics() { - cmgr.mr = metric_reader.NewReader(cfg.MetricsURL, cfg.ApiToken) + cmgr.ns = sampler.NewNodeSampler() homeDir, _ := os.UserHomeDir() dbPath := filepath.Join(homeDir, ".ehco", "metrics.db") @@ -241,10 +240,6 @@ func (cm *cmgrImpl) QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetri return cm.ms.QueryNodeMetric(ctx, req) } -func (cm *cmgrImpl) QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq) (*ms.QueryRuleMetricsResp, error) { - return cm.ms.QueryRuleMetric(ctx, req) -} - func (cm *cmgrImpl) DBHealth(ctx context.Context) (*ms.DBHealth, error) { if cm.ms == nil { return nil, ErrMetricsDisabled diff --git a/internal/cmgr/config.go b/internal/cmgr/config.go index e484cd3ac..60cb6d065 100644 --- a/internal/cmgr/config.go +++ b/internal/cmgr/config.go @@ -2,9 +2,12 @@ package cmgr type Config struct { SyncURL string - MetricsURL string - ApiToken string // bearer token for authed local /metrics/ pull - SyncInterval int // in seconds + SyncInterval int // in seconds + + // EnableMetrics opens the local SQLite metrics store and starts the + // host / rule samplers. Off when there is no web server to surface + // the data — sampling without a reader is just disk churn. + EnableMetrics bool } func (c *Config) NeedSync() bool { @@ -12,7 +15,7 @@ func (c *Config) NeedSync() bool { } func (c *Config) NeedMetrics() bool { - return c.MetricsURL != "" && c.SyncInterval > 0 + return c.EnableMetrics && c.SyncInterval > 0 } func (c *Config) Adjust() { diff --git a/internal/cmgr/ms/handler.go b/internal/cmgr/ms/handler.go index ea89b779d..ca58134ea 100644 --- a/internal/cmgr/ms/handler.go +++ b/internal/cmgr/ms/handler.go @@ -4,7 +4,7 @@ import ( "context" "database/sql" - "github.com/Ehco1996/ehco/pkg/metric_reader" + "github.com/Ehco1996/ehco/internal/cmgr/sampler" ) type NodeMetrics struct { @@ -32,39 +32,7 @@ type QueryNodeMetricsResp struct { Data []NodeMetrics `json:"data"` } -type RuleMetricsData struct { - Timestamp int64 `json:"timestamp"` - Label string `json:"label"` - Remote string `json:"remote"` - PingLatency int64 `json:"ping_latency"` - TCPConnectionCount int64 `json:"tcp_connection_count"` - TCPHandshakeDuration int64 `json:"tcp_handshake_duration"` - TCPNetworkTransmitBytes int64 `json:"tcp_network_transmit_bytes"` - UDPConnectionCount int64 `json:"udp_connection_count"` - UDPHandshakeDuration int64 `json:"udp_handshake_duration"` - UDPNetworkTransmitBytes int64 `json:"udp_network_transmit_bytes"` -} - -type QueryRuleMetricsReq struct { - RuleLabel string - Remote string - - StartTimestamp int64 - EndTimestamp int64 - Num int64 - // Step keeps the last sample per (label, remote) within each - // N-second bucket. Counter-style fields (transmit bytes) keep - // monotonic semantics so the SPA's delta-on-consecutive-points - // trend math still works after bucketing. - Step int64 -} - -type QueryRuleMetricsResp struct { - TOTAL int `json:"total"` - Data []RuleMetricsData `json:"data"` -} - -func (ms *MetricsStore) AddNodeMetric(ctx context.Context, m *metric_reader.NodeMetrics) error { +func (ms *MetricsStore) AddNodeMetric(ctx context.Context, m *sampler.NodeMetrics) error { defer track(&ms.stats.AddNode)() _, err := ms.db.ExecContext(ctx, ` INSERT OR REPLACE INTO node_metrics (timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out) @@ -80,46 +48,6 @@ func (ms *MetricsStore) AddNodeMetric(ctx context.Context, m *metric_reader.Node return nil } -func (ms *MetricsStore) AddRuleMetric(ctx context.Context, rm *metric_reader.RuleMetrics) error { - defer track(&ms.stats.AddRule)() - tx, err := ms.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() //nolint:errcheck - - stmt, err := tx.PrepareContext(ctx, ` - INSERT OR REPLACE INTO rule_metrics - (timestamp, label, remote, ping_latency, - tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes, - udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `) - if err != nil { - return err - } - defer stmt.Close() //nolint:errcheck - - var inserted int64 - for remote, pingMetric := range rm.PingMetrics { - _, err := stmt.ExecContext(ctx, rm.SyncTime.Unix(), rm.Label, remote, pingMetric.Latency, - rm.TCPConnectionCount[remote], rm.TCPHandShakeDuration[remote], rm.TCPNetworkTransmitBytes[remote], - rm.UDPConnectionCount[remote], rm.UDPHandShakeDuration[remote], rm.UDPNetworkTransmitBytes[remote]) - if err != nil { - return err - } - inserted++ - } - - if err := tx.Commit(); err != nil { - return err - } - // Same caveat as AddNodeMetric: REPLACE collapses, count is - // best-effort, reconciled on Vacuum / Truncate / restart. - ms.ruleRows.Add(inserted) - return nil -} - func (ms *MetricsStore) QueryNodeMetric(ctx context.Context, req *QueryNodeMetricsReq) (*QueryNodeMetricsResp, error) { defer track(&ms.stats.QueryNode)() var ( @@ -165,56 +93,3 @@ func (ms *MetricsStore) QueryNodeMetric(ctx context.Context, req *QueryNodeMetri resp.TOTAL = len(resp.Data) return &resp, nil } - -func (ms *MetricsStore) QueryRuleMetric(ctx context.Context, req *QueryRuleMetricsReq) (*QueryRuleMetricsResp, error) { - defer track(&ms.stats.QueryRule)() - // Bucketed mode keeps the last sample per (label, remote) inside each - // step-second window. The bytes columns are monotonic counters, so - // last-of-bucket preserves the deltas the SPA computes — averaging - // would smear the curve. - const cols = `timestamp, label, remote, ping_latency, - tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes, - udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes` - - whereSQL := "WHERE timestamp >= ? AND timestamp <= ?" - whereArgs := []interface{}{req.StartTimestamp, req.EndTimestamp} - if req.RuleLabel != "" { - whereSQL += " AND label = ?" - whereArgs = append(whereArgs, req.RuleLabel) - } - if req.Remote != "" { - whereSQL += " AND remote = ?" - whereArgs = append(whereArgs, req.Remote) - } - - var query string - var args []interface{} - if req.Step > 1 { - query = "SELECT " + cols + " FROM rule_metrics WHERE rowid IN (" + - "SELECT MAX(rowid) FROM rule_metrics " + whereSQL + - " GROUP BY (timestamp/?), label, remote) ORDER BY timestamp DESC LIMIT ?" - args = append(append([]interface{}{}, whereArgs...), req.Step, req.Num) - } else { - query = "SELECT " + cols + " FROM rule_metrics " + whereSQL + - " ORDER BY timestamp DESC LIMIT ?" - args = append(whereArgs, req.Num) - } - - rows, err := ms.db.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() //nolint:errcheck - var resp QueryRuleMetricsResp - for rows.Next() { - var m RuleMetricsData - if err := rows.Scan(&m.Timestamp, &m.Label, &m.Remote, &m.PingLatency, - &m.TCPConnectionCount, &m.TCPHandshakeDuration, &m.TCPNetworkTransmitBytes, - &m.UDPConnectionCount, &m.UDPHandshakeDuration, &m.UDPNetworkTransmitBytes); err != nil { - return nil, err - } - resp.Data = append(resp.Data, m) - } - resp.TOTAL = len(resp.Data) - return &resp, nil -} diff --git a/internal/cmgr/ms/health.go b/internal/cmgr/ms/health.go index 9b56857bc..ab4996ed8 100644 --- a/internal/cmgr/ms/health.go +++ b/internal/cmgr/ms/health.go @@ -17,15 +17,12 @@ type DBHealth struct { PageSize int64 `json:"db_page_size"` FreelistPages int64 `json:"db_freelist_pages"` NodeMetricsRows int64 `json:"node_metrics_rows"` - RuleMetricsRows int64 `json:"rule_metrics_rows"` - LastRuleWriteTs int64 `json:"last_rule_write_ts"` Stats map[string]OpStatsSnapshot `json:"stats"` } func (ms *MetricsStore) Health(ctx context.Context) (*DBHealth, error) { h := &DBHealth{ NodeMetricsRows: ms.nodeRows.Load(), - RuleMetricsRows: ms.ruleRows.Load(), Stats: ms.stats.Snapshot(), } if fi, err := os.Stat(ms.dbPath); err == nil { @@ -40,12 +37,6 @@ func (ms *MetricsStore) Health(ctx context.Context) (*DBHealth, error) { if err := ms.db.QueryRowContext(ctx, "PRAGMA freelist_count").Scan(&h.FreelistPages); err != nil { return nil, err } - // COALESCE keeps the JSON shape (always int64) even when the table - // is empty — caller distinguishes "never written" via the 0 value. - if err := ms.db.QueryRowContext(ctx, - "SELECT COALESCE(MAX(timestamp), 0) FROM rule_metrics").Scan(&h.LastRuleWriteTs); err != nil { - return nil, err - } return h, nil } @@ -54,14 +45,13 @@ func (ms *MetricsStore) Health(ctx context.Context) (*DBHealth, error) { // fill in NodeDeleted, Cleanup doesn't fill in BytesBefore, etc. type MaintenanceResult struct { NodeDeleted int64 `json:"node_deleted,omitempty"` - RuleDeleted int64 `json:"rule_deleted,omitempty"` BytesBefore int64 `json:"bytes_before,omitempty"` BytesAfter int64 `json:"bytes_after,omitempty"` DurationMs int64 `json:"duration_ms"` } -// CleanupOlderThan deletes rows older than `days` from both metrics -// tables. days <= 0 falls back to the historical 30-day default. +// CleanupOlderThan deletes rows older than `days` from node_metrics. +// days <= 0 falls back to the historical 30-day default. func (ms *MetricsStore) CleanupOlderThan(ctx context.Context, days int) (*MaintenanceResult, error) { defer track(&ms.stats.Cleanup)() if days <= 0 { @@ -69,14 +59,13 @@ func (ms *MetricsStore) CleanupOlderThan(ctx context.Context, days int) (*Mainte } start := time.Now() cutoff := time.Now().AddDate(0, 0, -days).Unix() - nodeDel, ruleDel, err := ms.deleteOlderThan(cutoff) + nodeDel, err := ms.deleteOlderThan(cutoff) if err != nil { return nil, err } _ = ctx // ctx kept for symmetry; deleteOlderThan uses ms.db directly return &MaintenanceResult{ NodeDeleted: nodeDel, - RuleDeleted: ruleDel, DurationMs: time.Since(start).Milliseconds(), }, nil } @@ -114,8 +103,8 @@ var ErrTruncateNotConfirmed = errors.New("truncate requires confirm=\"yes I am s // an explicit, typed phrase counts. const truncateConfirm = "yes I am sure" -// Truncate empties both metrics tables and reclaims the freelist via -// VACUUM. The confirm string must match truncateConfirm exactly. +// Truncate empties node_metrics and reclaims the freelist via VACUUM. +// The confirm string must match truncateConfirm exactly. func (ms *MetricsStore) Truncate(ctx context.Context, confirm string) (*MaintenanceResult, error) { if confirm != truncateConfirm { return nil, ErrTruncateNotConfirmed @@ -124,13 +113,9 @@ func (ms *MetricsStore) Truncate(ctx context.Context, confirm string) (*Maintena start := time.Now() before := ms.dbFileSize() nodeBefore := ms.nodeRows.Load() - ruleBefore := ms.ruleRows.Load() if _, err := ms.db.ExecContext(ctx, "DELETE FROM node_metrics"); err != nil { return nil, err } - if _, err := ms.db.ExecContext(ctx, "DELETE FROM rule_metrics"); err != nil { - return nil, err - } if _, err := ms.db.ExecContext(ctx, "VACUUM"); err != nil { return nil, err } @@ -138,11 +123,9 @@ func (ms *MetricsStore) Truncate(ctx context.Context, confirm string) (*Maintena return nil, err } after := ms.dbFileSize() - ms.l.Warnf("truncate: deleted node=%d rule=%d, %d -> %d bytes", - nodeBefore, ruleBefore, before, after) + ms.l.Warnf("truncate: deleted node=%d, %d -> %d bytes", nodeBefore, before, after) return &MaintenanceResult{ NodeDeleted: nodeBefore, - RuleDeleted: ruleBefore, BytesBefore: before, BytesAfter: after, DurationMs: time.Since(start).Milliseconds(), diff --git a/internal/cmgr/ms/health_test.go b/internal/cmgr/ms/health_test.go index 2f8e9acd7..9b9a21dca 100644 --- a/internal/cmgr/ms/health_test.go +++ b/internal/cmgr/ms/health_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/Ehco1996/ehco/pkg/metric_reader" + "github.com/Ehco1996/ehco/internal/cmgr/sampler" ) func newTestStore(t *testing.T) *MetricsStore { @@ -25,8 +25,8 @@ func TestHealth_EmptyStore(t *testing.T) { if err != nil { t.Fatalf("Health: %v", err) } - if h.NodeMetricsRows != 0 || h.RuleMetricsRows != 0 { - t.Fatalf("expected empty store, got node=%d rule=%d", h.NodeMetricsRows, h.RuleMetricsRows) + if h.NodeMetricsRows != 0 { + t.Fatalf("expected empty store, got node=%d", h.NodeMetricsRows) } if h.PageSize == 0 { t.Fatalf("page size should be reported (got 0)") @@ -41,7 +41,7 @@ func TestHealth_TracksWritesAndQueries(t *testing.T) { ctx := context.Background() now := time.Now() - if err := ms.AddNodeMetric(ctx, &metric_reader.NodeMetrics{ + if err := ms.AddNodeMetric(ctx, &sampler.NodeMetrics{ SyncTime: now, CpuUsagePercent: 1, MemoryUsagePercent: 2, @@ -87,7 +87,7 @@ func TestCleanupOlderThan_RemovesAndReportsCounts(t *testing.T) { old := time.Now().Add(-90 * 24 * time.Hour) fresh := time.Now() for _, ts := range []time.Time{old, fresh} { - if err := ms.AddNodeMetric(ctx, &metric_reader.NodeMetrics{SyncTime: ts}); err != nil { + if err := ms.AddNodeMetric(ctx, &sampler.NodeMetrics{SyncTime: ts}); err != nil { t.Fatalf("AddNodeMetric: %v", err) } } @@ -108,7 +108,7 @@ func TestCleanupOlderThan_RemovesAndReportsCounts(t *testing.T) { func TestTruncate_RequiresExactConfirm(t *testing.T) { ms := newTestStore(t) ctx := context.Background() - if err := ms.AddNodeMetric(ctx, &metric_reader.NodeMetrics{SyncTime: time.Now()}); err != nil { + if err := ms.AddNodeMetric(ctx, &sampler.NodeMetrics{SyncTime: time.Now()}); err != nil { t.Fatalf("AddNodeMetric: %v", err) } @@ -129,7 +129,7 @@ func TestTruncate_RequiresExactConfirm(t *testing.T) { func TestResetStats_ClearsCounters(t *testing.T) { ms := newTestStore(t) ctx := context.Background() - _ = ms.AddNodeMetric(ctx, &metric_reader.NodeMetrics{SyncTime: time.Now()}) + _ = ms.AddNodeMetric(ctx, &sampler.NodeMetrics{SyncTime: time.Now()}) if h, _ := ms.Health(ctx); h.Stats["add_node"].Count != 1 { t.Fatalf("setup: expected add_node count=1") } diff --git a/internal/cmgr/ms/ms.go b/internal/cmgr/ms/ms.go index d9f24f71d..ca16990c0 100644 --- a/internal/cmgr/ms/ms.go +++ b/internal/cmgr/ms/ms.go @@ -25,14 +25,13 @@ type MetricsStore struct { // method on this store. See stats.go. stats Stats - // nodeRows / ruleRows are best-effort row-count caches kept in - // sync with INSERT / DELETE so Health() doesn't need a per-call - // SELECT COUNT(*). Refreshed on startup, recomputed after - // Cleanup / Truncate / Vacuum where the exact post-state matters. - // INSERT OR REPLACE on a duplicate PK can briefly overcount; the - // drift is bounded and resets every time Recount() runs. + // nodeRows is a best-effort row-count cache kept in sync with INSERT + // / DELETE so Health() doesn't need a per-call SELECT COUNT(*). + // Refreshed on startup, recomputed after Cleanup / Truncate / Vacuum + // where the exact post-state matters. INSERT OR REPLACE on a + // duplicate PK can briefly overcount; the drift is bounded and + // resets every time recountRows() runs. nodeRows atomic.Int64 - ruleRows atomic.Int64 } func NewMetricsStore(dbPath string) (*MetricsStore, error) { @@ -76,51 +75,35 @@ func (ms *MetricsStore) Close() error { func (ms *MetricsStore) cleanOldData() error { defer track(&ms.stats.Cleanup)() cutoff := time.Now().AddDate(0, 0, -defaultRetentionDays).Unix() - _, _, err := ms.deleteOlderThan(cutoff) + _, err := ms.deleteOlderThan(cutoff) return err } -// deleteOlderThan runs the two-table prune and returns the number of -// rows removed from each. Centralises the SQL so cleanOldData and the -// CleanupOlderThan API path stay consistent. -func (ms *MetricsStore) deleteOlderThan(cutoff int64) (nodeDeleted, ruleDeleted int64, err error) { +func (ms *MetricsStore) deleteOlderThan(cutoff int64) (nodeDeleted int64, err error) { res, err := ms.db.Exec("DELETE FROM node_metrics WHERE timestamp < ?", cutoff) if err != nil { - return 0, 0, err + return 0, err } nodeDeleted, _ = res.RowsAffected() - - res, err = ms.db.Exec("DELETE FROM rule_metrics WHERE timestamp < ?", cutoff) - if err != nil { - return nodeDeleted, 0, err - } - ruleDeleted, _ = res.RowsAffected() - ms.nodeRows.Add(-nodeDeleted) - ms.ruleRows.Add(-ruleDeleted) - ms.l.Infof("pruned node_metrics=%d rule_metrics=%d (cutoff=%d)", nodeDeleted, ruleDeleted, cutoff) - return nodeDeleted, ruleDeleted, nil + ms.l.Infof("pruned node_metrics=%d (cutoff=%d)", nodeDeleted, cutoff) + return nodeDeleted, nil } -// recountRows refreshes the cached row counts from the source of truth. +// recountRows refreshes the cached row count from the source of truth. // Cheap on startup (db usually small, even at 30d full retention); we // also call it after Truncate / Vacuum where the cache may have drifted // or been wiped wholesale. func (ms *MetricsStore) recountRows() error { - var nodeRows, ruleRows int64 + var nodeRows int64 if err := ms.db.QueryRow("SELECT COUNT(*) FROM node_metrics").Scan(&nodeRows); err != nil { return err } - if err := ms.db.QueryRow("SELECT COUNT(*) FROM rule_metrics").Scan(&ruleRows); err != nil { - return err - } ms.nodeRows.Store(nodeRows) - ms.ruleRows.Store(ruleRows) return nil } func (ms *MetricsStore) initDB() error { - // init NodeMetrics table if _, err := ms.db.Exec(` CREATE TABLE IF NOT EXISTS node_metrics ( timestamp INTEGER, @@ -134,23 +117,11 @@ func (ms *MetricsStore) initDB() error { `); err != nil { return err } - - // init rule_metrics - if _, err := ms.db.Exec(` - CREATE TABLE IF NOT EXISTS rule_metrics ( - timestamp INTEGER, - label TEXT, - remote TEXT, - ping_latency INTEGER, - tcp_connection_count INTEGER, - tcp_handshake_duration BIGINT, - tcp_network_transmit_bytes BIGINT, - udp_connection_count INTEGER, - udp_handshake_duration BIGINT, - udp_network_transmit_bytes BIGINT, - PRIMARY KEY (timestamp, label, remote) - ) - `); err != nil { + // Drop any pre-existing rule_metrics table from previous releases — + // the rule-level metrics feature was removed and will be rebuilt + // from scratch. Leaving the table around just wastes pages on + // upgraded nodes. + if _, err := ms.db.Exec(`DROP TABLE IF EXISTS rule_metrics`); err != nil { return err } return nil diff --git a/internal/cmgr/sampler/node.go b/internal/cmgr/sampler/node.go new file mode 100644 index 000000000..c7112b3ea --- /dev/null +++ b/internal/cmgr/sampler/node.go @@ -0,0 +1,88 @@ +package sampler + +import ( + "context" + "fmt" + "math" + "runtime" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/load" + "github.com/shirou/gopsutil/v4/mem" + psnet "github.com/shirou/gopsutil/v4/net" + "go.uber.org/zap" +) + +// NodeSampler reads host stats directly via gopsutil. It replaces the +// node_exporter → /metrics → expfmt detour the project used to take when +// the source and sink lived in the same Go process. +// +// Sample is not goroutine-safe; the caller (cmgr's tick loop) is expected +// to invoke it serially. +type NodeSampler struct { + last *NodeMetrics + l *zap.SugaredLogger +} + +func NewNodeSampler() *NodeSampler { + return &NodeSampler{l: zap.S().Named("sampler.node")} +} + +func (s *NodeSampler) Sample(ctx context.Context) (*NodeMetrics, error) { + now := time.Now() + nm := &NodeMetrics{SyncTime: now} + + if pct, err := cpu.PercentWithContext(ctx, 0, false); err == nil && len(pct) > 0 { + nm.CpuUsagePercent = round2(pct[0]) + } else if err != nil { + s.l.Debugf("cpu.Percent: %v", err) + } + nm.CpuCoreCount = runtime.NumCPU() + + if avg, err := load.AvgWithContext(ctx); err == nil { + nm.CpuLoadInfo = fmt.Sprintf("%.2f|%.2f|%.2f", avg.Load1, avg.Load5, avg.Load15) + } else { + s.l.Debugf("load.Avg: %v", err) + } + + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + nm.MemoryTotalBytes = int64(vm.Total) + nm.MemoryUsageBytes = int64(vm.Used) + nm.MemoryUsagePercent = round2(vm.UsedPercent) + } else { + s.l.Debugf("mem.VirtualMemory: %v", err) + } + + if du, err := disk.UsageWithContext(ctx, "/"); err == nil { + nm.DiskTotalBytes = int64(du.Total) + nm.DiskUsageBytes = int64(du.Used) + nm.DiskUsagePercent = round2(du.UsedPercent) + } else { + s.l.Debugf("disk.Usage(/): %v", err) + } + + if io, err := psnet.IOCountersWithContext(ctx, false); err == nil && len(io) > 0 { + nm.NetworkReceiveBytesTotal = int64(io[0].BytesRecv) + nm.NetworkTransmitBytesTotal = int64(io[0].BytesSent) + } else if err != nil { + s.l.Debugf("net.IOCounters: %v", err) + } + + if s.last != nil { + dur := now.Sub(s.last.SyncTime).Seconds() + if dur > 0.1 { + nm.NetworkReceiveBytesRate = math.Round(math.Max(0, + float64(nm.NetworkReceiveBytesTotal-s.last.NetworkReceiveBytesTotal)/dur)) + nm.NetworkTransmitBytesRate = math.Round(math.Max(0, + float64(nm.NetworkTransmitBytesTotal-s.last.NetworkTransmitBytesTotal)/dur)) + } + } + s.last = nm + return nm, nil +} + +func round2(f float64) float64 { + return math.Round(f*100) / 100 +} diff --git a/internal/cmgr/sampler/types.go b/internal/cmgr/sampler/types.go new file mode 100644 index 000000000..6b369d1ec --- /dev/null +++ b/internal/cmgr/sampler/types.go @@ -0,0 +1,24 @@ +package sampler + +import "time" + +type NodeMetrics struct { + CpuCoreCount int `json:"cpu_core_count"` + CpuLoadInfo string `json:"cpu_load_info"` + CpuUsagePercent float64 `json:"cpu_usage_percent"` + + MemoryTotalBytes int64 `json:"memory_total_bytes"` + MemoryUsageBytes int64 `json:"memory_usage_bytes"` + MemoryUsagePercent float64 `json:"memory_usage_percent"` + + DiskTotalBytes int64 `json:"disk_total_bytes"` + DiskUsageBytes int64 `json:"disk_usage_bytes"` + DiskUsagePercent float64 `json:"disk_usage_percent"` + + NetworkReceiveBytesTotal int64 `json:"network_receive_bytes_total"` + NetworkTransmitBytesTotal int64 `json:"network_transmit_bytes_total"` + NetworkReceiveBytesRate float64 `json:"network_receive_bytes_rate"` + NetworkTransmitBytesRate float64 `json:"network_transmit_bytes_rate"` + + SyncTime time.Time +} diff --git a/internal/cmgr/syncer.go b/internal/cmgr/syncer.go index a4c459243..b1d01d836 100644 --- a/internal/cmgr/syncer.go +++ b/internal/cmgr/syncer.go @@ -3,10 +3,10 @@ package cmgr import ( "context" + "github.com/Ehco1996/ehco/internal/cmgr/sampler" "github.com/Ehco1996/ehco/internal/conn" "github.com/Ehco1996/ehco/internal/constant" myhttp "github.com/Ehco1996/ehco/pkg/http" - "github.com/Ehco1996/ehco/pkg/metric_reader" "go.uber.org/zap" ) @@ -24,27 +24,22 @@ type VersionInfo struct { ShortCommit string `json:"short_commit"` } -// sampleMetrics reads /metrics/ once and persists node + per-rule rows -// to the local store. Cheap; called on every fast tick so the dashboard -// has sub-minute resolution regardless of whether control-plane sync is -// configured. +// sampleMetrics samples host stats once and persists them to the local +// store. Cheap enough to run on every fast tick so the dashboard's Node +// page has sub-minute resolution regardless of whether control-plane +// sync is configured. func (cm *cmgrImpl) sampleMetrics(ctx context.Context) { if !cm.cfg.NeedMetrics() { return } - nm, rmm, err := cm.mr.ReadOnce(ctx) + nm, err := cm.ns.Sample(ctx) if err != nil { - cm.l.Debugf("metrics sample failed: %v", err) + cm.l.Debugf("node sample failed: %v", err) return } if err := cm.ms.AddNodeMetric(ctx, nm); err != nil { cm.l.Errorf("persist node metric: %v", err) } - for _, rm := range rmm { - if err := cm.ms.AddRuleMetric(ctx, rm); err != nil { - cm.l.Errorf("persist rule metric: %v", err) - } - } } // pushStats drains closedConnectionsMap and POSTs accumulated traffic @@ -64,8 +59,8 @@ func (cm *cmgrImpl) pushStats(ctx context.Context) error { } if cm.cfg.NeedMetrics() { - if nm, _, err := cm.mr.ReadOnce(ctx); err != nil { - cm.l.Errorf("read metrics for sync: %v", err) + if nm, err := cm.ns.Sample(ctx); err != nil { + cm.l.Errorf("sample node metrics for sync: %v", err) } else { req.Node = *nm } @@ -97,7 +92,7 @@ func (cm *cmgrImpl) pushStats(ctx context.Context) error { } type syncReq struct { - Version VersionInfo `json:"version"` - Node metric_reader.NodeMetrics `json:"node"` - Stats []StatsPerRule `json:"stats"` + Version VersionInfo `json:"version"` + Node sampler.NodeMetrics `json:"node"` + Stats []StatsPerRule `json:"stats"` } diff --git a/internal/config/config.go b/internal/config/config.go index 703f520ad..70aff7f8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -141,12 +141,3 @@ func (c *Config) NeedStartCmgr() bool { return c.RelaySyncURL != "" && c.RelaySyncInterval > 0 } -func (c *Config) GetMetricURL() string { - if !c.NeedStartWebServer() { - return "" - } - // Plain URL: no creds in query, no creds in basic-auth userinfo. - // Internal callers attach Authorization: Bearer as a - // header instead — see metric_reader / bandwidth_recorder. - return fmt.Sprintf("http://%s:%d/metrics/", c.WebHost, c.WebPort) -} diff --git a/internal/metrics/log_level.go b/internal/metrics/log_level.go deleted file mode 100644 index a9bdc2f26..000000000 --- a/internal/metrics/log_level.go +++ /dev/null @@ -1,21 +0,0 @@ -package metrics - -import ( - "log/slog" - "strings" -) - -func zapLevelToSlogLevel(zapLevel string) slog.Level { - switch strings.ToLower(zapLevel) { - case "debug": - return slog.LevelDebug - case "info": - return slog.LevelInfo - case "warn", "warning": - return slog.LevelWarn - case "error": - return slog.LevelError - default: - return slog.LevelInfo - } -} diff --git a/internal/metrics/node_darwin.go b/internal/metrics/node_darwin.go deleted file mode 100644 index c7bd1ee66..000000000 --- a/internal/metrics/node_darwin.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build darwin - -package metrics - -import ( - "fmt" - "log/slog" - "os" - - "github.com/Ehco1996/ehco/internal/config" - "github.com/alecthomas/kingpin/v2" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/node_exporter/collector" -) - -// `thermal` collector logs an ERROR per scrape on most Macs with -// "no CPU power status has been recorded". Disable it via kingpin so -// logs stay quiet. Kept separate from node_linux.go because Linux -// doesn't have the collector and shouldn't carry the flag noise. -func RegisterNodeExporterMetrics(cfg *config.Config) error { - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: zapLevelToSlogLevel(cfg.LogLeveL), - })) - - if _, err := kingpin.CommandLine.Parse([]string{ - "--no-collector.thermal", - }); err != nil { - return err - } - nc, err := collector.NewNodeCollector(logger) - if err != nil { - return fmt.Errorf("couldn't create collector: %w", err) - } - prometheus.MustRegister(nc) - return nil -} diff --git a/internal/metrics/node_linux.go b/internal/metrics/node_linux.go deleted file mode 100644 index ec9960511..000000000 --- a/internal/metrics/node_linux.go +++ /dev/null @@ -1,33 +0,0 @@ -//go:build linux - -package metrics - -import ( - "fmt" - "log/slog" - "os" - - "github.com/Ehco1996/ehco/internal/config" - "github.com/alecthomas/kingpin/v2" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/node_exporter/collector" -) - -func RegisterNodeExporterMetrics(cfg *config.Config) error { - slogLevel := zapLevelToSlogLevel(cfg.LogLeveL) - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slogLevel, - })) - - // node_exporter relay on `kingpin` to enable default node collector - // see https://github.com/prometheus/node_exporter/pull/2463 - if _, err := kingpin.CommandLine.Parse([]string{}); err != nil { - return err - } - nc, err := collector.NewNodeCollector(logger) - if err != nil { - return fmt.Errorf("couldn't create collector: %w", err) - } - prometheus.MustRegister(nc) - return nil -} diff --git a/internal/relay/server.go b/internal/relay/server.go index 689b24077..2741a9617 100644 --- a/internal/relay/server.go +++ b/internal/relay/server.go @@ -30,10 +30,9 @@ type Server struct { func NewServer(cfg *config.Config) (*Server, error) { l := zap.S().Named("relay-server") cmgrCfg := &cmgr.Config{ - SyncURL: cfg.RelaySyncURL, - SyncInterval: cfg.RelaySyncInterval, - MetricsURL: cfg.GetMetricURL(), - ApiToken: cfg.ApiToken, + SyncURL: cfg.RelaySyncURL, + SyncInterval: cfg.RelaySyncInterval, + EnableMetrics: cfg.NeedStartWebServer(), } cmgrCfg.Adjust() cmgr, err := cmgr.NewCmgr(cmgrCfg) diff --git a/internal/web/handler_api.go b/internal/web/handler_api.go index dbc449cd9..09339a9be 100644 --- a/internal/web/handler_api.go +++ b/internal/web/handler_api.go @@ -79,30 +79,6 @@ func (s *Server) GetNodeMetrics(c echo.Context) error { return c.JSON(http.StatusOK, metrics) } -func (s *Server) GetRuleMetrics(c echo.Context) error { - params, err := parseQueryParams(c) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) - } - req := &ms.QueryRuleMetricsReq{ - StartTimestamp: params.startTS, - EndTimestamp: params.endTS, - Num: -1, - Step: params.step, - RuleLabel: c.QueryParam("label"), - Remote: c.QueryParam("remote"), - } - if params.latest { - req.Num = 1 - } - - metrics, err := s.connMgr.QueryRuleMetrics(c.Request().Context(), req) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - return c.JSON(http.StatusOK, metrics) -} - // AuthInfo reports whether the server requires login and whether the // current request already carries a valid session/bearer. The SPA boots // off this — if auth_required is false it skips LoginGate entirely; if diff --git a/internal/web/server.go b/internal/web/server.go index 6c03cfd19..c1af23aee 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -105,9 +105,6 @@ func setupMetrics(cfg *config.Config) error { if err := metrics.RegisterEhcoMetrics(cfg); err != nil { return fmt.Errorf("failed to register Ehco metrics: %w", err) } - if err := metrics.RegisterNodeExporterMetrics(cfg); err != nil { - return fmt.Errorf("failed to register Node Exporter metrics: %w", err) - } return nil } @@ -125,7 +122,6 @@ func setupRoutes(s *Server) { api.POST("/config/reload/", s.HandleReload) api.GET("/health_check/", s.HandleHealthCheck) api.GET("/node_metrics/", s.GetNodeMetrics) - api.GET("/rule_metrics/", s.GetRuleMetrics) api.GET("/overview", s.Overview) api.GET("/version", s.Version) api.GET("/update/check", s.UpdateCheck) diff --git a/internal/web/webui/src/api/client.ts b/internal/web/webui/src/api/client.ts index 37445f0ef..373cf006d 100644 --- a/internal/web/webui/src/api/client.ts +++ b/internal/web/webui/src/api/client.ts @@ -33,7 +33,6 @@ import type { EhcoConfig, HealthCheckResp, QueryNodeMetricsResp, - QueryRuleMetricsResp, VersionInfo, UpdateCheck, UpdateStatus, @@ -59,20 +58,6 @@ export const api = { if (params.step && params.step > 1) q.set("step", String(params.step)); return request(`/api/v1/node_metrics/?${q.toString()}`); }, - ruleMetrics: (params: { - label?: string; - remote?: string; - start_ts?: number; - end_ts?: number; - latest?: boolean; - step?: number; - }) => { - const q = new URLSearchParams(); - for (const [k, v] of Object.entries(params)) { - if (v != null && v !== "") q.set(k, String(v)); - } - return request(`/api/v1/rule_metrics/?${q.toString()}`); - }, overview: () => request("/api/v1/overview"), xrayConns: (userId?: number) => { const q = userId ? `?user=${userId}` : ""; diff --git a/internal/web/webui/src/api/types.ts b/internal/web/webui/src/api/types.ts index 5b7d4a647..e0b120422 100644 --- a/internal/web/webui/src/api/types.ts +++ b/internal/web/webui/src/api/types.ts @@ -54,24 +54,6 @@ export interface QueryNodeMetricsResp { data: NodeMetric[]; } -export interface RuleMetric { - timestamp: number; - label: string; - remote: string; - ping_latency: number; - tcp_connection_count: number; - tcp_handshake_duration: number; - tcp_network_transmit_bytes: number; - udp_connection_count: number; - udp_handshake_duration: number; - udp_network_transmit_bytes: number; -} - -export interface QueryRuleMetricsResp { - total: number; - data: RuleMetric[]; -} - export interface HealthCheckResp { error_code: number; msg: string; @@ -174,14 +156,11 @@ export interface DBHealth { db_page_size: number; db_freelist_pages: number; node_metrics_rows: number; - rule_metrics_rows: number; - last_rule_write_ts: number; stats: Record; } export interface DBMaintenanceResult { node_deleted?: number; - rule_deleted?: number; bytes_before?: number; bytes_after?: number; duration_ms: number; diff --git a/internal/web/webui/src/pages/Rules.tsx b/internal/web/webui/src/pages/Rules.tsx index eff745648..8b6089d7d 100644 --- a/internal/web/webui/src/pages/Rules.tsx +++ b/internal/web/webui/src/pages/Rules.tsx @@ -1,23 +1,12 @@ import { createMemo, createResource, createSignal, Show } from "solid-js"; -import { RefreshCcw, ServerCog, Heart } from "lucide-solid"; +import { ServerCog, Heart } from "lucide-solid"; import PageHeader from "../ui/PageHeader"; import Button from "../ui/Button"; import { Pill } from "../ui/Pill"; import EmptyState from "../ui/EmptyState"; -import Sparkline from "../ui/Sparkline"; -import Segmented from "../ui/Segmented"; import DataTable, { Column } from "../ui/DataTable"; import { api, ApiError } from "../api/client"; -import { bytes, pickStep } from "../util/format"; -import type { RelayConfig, RuleMetric } from "../api/types"; - -const HISTORY_WINDOWS = [ - { value: 60 * 60, label: "1h" }, - { value: 6 * 60 * 60, label: "6h" }, - { value: 24 * 60 * 60, label: "24h" }, - { value: 7 * 24 * 60 * 60, label: "7d" }, - { value: 30 * 24 * 60 * 60, label: "30d" }, -] as const; +import type { RelayConfig } from "../api/types"; interface HCResult { state: "running" | "ok" | "err"; @@ -27,24 +16,10 @@ interface HCResult { interface Row { cfg: RelayConfig; remote: string; - metric: RuleMetric | undefined; - series: number[]; } export default function Rules() { const [config] = createResource(() => api.config()); - const [latest, { refetch: rcLatest }] = createResource(() => - api.ruleMetrics({ latest: true }), - ); - const [historyWindow, setHistoryWindow] = createSignal(HISTORY_WINDOWS[0].value); - const [history, { refetch: rcHistory }] = createResource(historyWindow, async (sec) => { - const end = Math.floor(Date.now() / 1000); - return api.ruleMetrics({ - start_ts: end - sec, - end_ts: end, - step: pickStep(sec), - }); - }); const [hc, setHc] = createSignal>({}); const ruleList = (): RelayConfig[] => { @@ -52,52 +27,13 @@ export default function Rules() { return Array.isArray(c) ? c : []; }; - const latestFor = (label: string, remote: string) => - (latest()?.data ?? []).find( - (m) => m.label === label && m.remote === remote, - ); - - const historyByKey = createMemo(() => { - const out = new Map(); - const points = history()?.data ?? []; - if (!points.length) return out; - const grouped = new Map(); - for (const p of points) { - const k = `${p.label}|${p.remote}`; - (grouped.get(k) ?? grouped.set(k, []).get(k)!).push(p); - } - for (const [k, arr] of grouped) { - arr.sort((a, b) => a.timestamp - b.timestamp); - const deltas: number[] = []; - for (let i = 1; i < arr.length; i++) { - const d = - arr[i].tcp_network_transmit_bytes - - arr[i - 1].tcp_network_transmit_bytes; - deltas.push(Math.max(0, d)); - } - out.set(k, deltas); - } - return out; - }); - const rows = createMemo(() => ruleList().map((cfg) => { const remotes = [...(cfg.tcp_remotes ?? []), ...(cfg.udp_remotes ?? [])]; - const remote = remotes[0] ?? ""; - return { - cfg, - remote, - metric: latestFor(cfg.label ?? "", remote), - series: historyByKey().get(`${cfg.label}|${remote}`) ?? [], - }; + return { cfg, remote: remotes[0] ?? "" }; }), ); - const refreshAll = () => { - rcLatest(); - rcHistory(); - }; - const checkOne = async (label: string) => { if (!label) return; setHc({ ...hc(), [label]: { state: "running", text: "checking…" } }); @@ -158,52 +94,6 @@ export default function Rules() { ), mdOnly: true, }, - { - key: "trend", - header: `${HISTORY_WINDOWS.find((w) => w.value === historyWindow())?.label ?? ""} trend`, - cell: (r) => ( - - - - ), - mdOnly: true, - }, - { - key: "xfer", - header: "tcp xfer", - align: "right", - cell: (r) => ( - - {r.metric ? bytes(r.metric.tcp_network_transmit_bytes) : "—"} - - ), - sortable: true, - sortBy: (r) => r.metric?.tcp_network_transmit_bytes ?? 0, - }, - { - key: "conns", - header: "conns", - align: "right", - cell: (r) => ( - {r.metric?.tcp_connection_count ?? "—"} - ), - sortable: true, - sortBy: (r) => r.metric?.tcp_connection_count ?? 0, - width: "80px", - }, - { - key: "ping", - header: "ping", - align: "right", - cell: (r) => ( - - {r.metric?.ping_latency != null ? `${r.metric.ping_latency}ms` : "—"} - - ), - sortable: true, - sortBy: (r) => r.metric?.ping_latency ?? Infinity, - width: "80px", - }, { key: "probe", header: "probe", @@ -246,24 +136,7 @@ export default function Rules() { <> - ({ value: w.value, label: w.label }))} - value={historyWindow()} - onChange={setHistoryWindow} - size="sm" - /> - - - } + subtitle="static relay rules — per-rule metrics removed pending rewrite" /> @@ -271,7 +144,6 @@ export default function Rules() { columns={columns} rowKey={(r) => `${r.cfg.label}|${r.remote}`} pageSize={50} - defaultSort={{ key: "xfer", dir: "desc" }} empty={ } diff --git a/internal/web/webui/src/pages/Settings.tsx b/internal/web/webui/src/pages/Settings.tsx index bcd048cd6..fa72d1b69 100644 --- a/internal/web/webui/src/pages/Settings.tsx +++ b/internal/web/webui/src/pages/Settings.tsx @@ -92,7 +92,7 @@ export default function Settings() { () => api.dbCleanup(cleanupDays()), (r) => { const m = r as DBMaintenanceResult; - return `pruned node=${m.node_deleted ?? 0} rule=${m.rule_deleted ?? 0} in ${m.duration_ms}ms`; + return `pruned node=${m.node_deleted ?? 0} in ${m.duration_ms}ms`; }, ); @@ -127,7 +127,7 @@ export default function Settings() { () => api.dbTruncate(got), (r) => { const m = r as DBMaintenanceResult; - return `wiped node=${m.node_deleted ?? 0} rule=${m.rule_deleted ?? 0}`; + return `wiped node=${m.node_deleted ?? 0}`; }, ); }; @@ -259,14 +259,6 @@ function StorageCard(props: { h: DBHealth }) { const pc = props.h.db_page_count; return pc > 0 ? (props.h.db_freelist_pages / pc) * 100 : 0; }; - const lastWriteText = () => { - if (!props.h.last_rule_write_ts) return "never"; - const ageSec = Math.max(0, Date.now() / 1000 - props.h.last_rule_write_ts); - if (ageSec < 60) return `${Math.round(ageSec)}s ago`; - if (ageSec < 3600) return `${Math.round(ageSec / 60)}m ago`; - if (ageSec < 86400) return `${Math.round(ageSec / 3600)}h ago`; - return `${Math.round(ageSec / 86400)}d ago`; - }; return ( @@ -282,11 +274,6 @@ function StorageCard(props: { h: DBHealth }) { `${props.h.db_freelist_pages.toLocaleString()} (${fragPct().toFixed(1)}%)${fragPct() > 30 ? " — VACUUM recommended" : ""}`, ], ["node_metrics", `${props.h.node_metrics_rows.toLocaleString()} rows`], - [ - "rule_metrics", - `${props.h.rule_metrics_rows.toLocaleString()} rows${props.h.rule_metrics_rows === 0 ? " — no data, check sync pipeline" : ""}`, - ], - ["last rule write", lastWriteText()], ]} /> diff --git a/pkg/metric_reader/node.go b/pkg/metric_reader/node.go deleted file mode 100644 index 9a6905e85..000000000 --- a/pkg/metric_reader/node.go +++ /dev/null @@ -1,185 +0,0 @@ -package metric_reader - -import ( - "fmt" - "math" - "strings" - "time" - - dto "github.com/prometheus/client_model/go" -) - -const ( - metricCPUSecondsTotal = "node_cpu_seconds_total" - metricLoad1 = "node_load1" - metricLoad5 = "node_load5" - metricLoad15 = "node_load15" - metricMemoryTotalBytes = "node_memory_total_bytes" - metricMemoryActiveBytes = "node_memory_active_bytes" - metricMemoryWiredBytes = "node_memory_wired_bytes" - metricMemoryMemTotalBytes = "node_memory_MemTotal_bytes" - metricMemoryMemAvailableBytes = "node_memory_MemAvailable_bytes" - metricFilesystemSizeBytes = "node_filesystem_size_bytes" - metricFilesystemAvailBytes = "node_filesystem_avail_bytes" - metricNetworkReceiveBytesTotal = "node_network_receive_bytes_total" - metricNetworkTransmitBytesTotal = "node_network_transmit_bytes_total" -) - -type NodeMetrics struct { - // cpu - CpuCoreCount int `json:"cpu_core_count"` - CpuLoadInfo string `json:"cpu_load_info"` - CpuUsagePercent float64 `json:"cpu_usage_percent"` - - // memory - MemoryTotalBytes int64 `json:"memory_total_bytes"` - MemoryUsageBytes int64 `json:"memory_usage_bytes"` - MemoryUsagePercent float64 `json:"memory_usage_percent"` - - // disk - DiskTotalBytes int64 `json:"disk_total_bytes"` - DiskUsageBytes int64 `json:"disk_usage_bytes"` - DiskUsagePercent float64 `json:"disk_usage_percent"` - - // network - NetworkReceiveBytesTotal int64 `json:"network_receive_bytes_total"` - NetworkTransmitBytesTotal int64 `json:"network_transmit_bytes_total"` - NetworkReceiveBytesRate float64 `json:"network_receive_bytes_rate"` - NetworkTransmitBytesRate float64 `json:"network_transmit_bytes_rate"` - - SyncTime time.Time -} -type cpuStats struct { - totalTime float64 - idleTime float64 - cores int -} - -func (b *readerImpl) ParseNodeMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error { - isMac := metricMap[metricMemoryTotalBytes] != nil - cpu := &cpuStats{} - - b.processCPUMetrics(metricMap, cpu) - b.processMemoryMetrics(metricMap, nm, isMac) - b.processDiskMetrics(metricMap, nm) - b.processNetworkMetrics(metricMap, nm) - b.processLoadMetrics(metricMap, nm) - - b.calculateFinalMetrics(nm, cpu) - return nil -} - -func (b *readerImpl) processCPUMetrics(metricMap map[string]*dto.MetricFamily, cpu *cpuStats) { - if cpuMetric, ok := metricMap[metricCPUSecondsTotal]; ok { - for _, metric := range cpuMetric.Metric { - value := getMetricValue(metric, cpuMetric.GetType()) - cpu.totalTime += value - if getLabel(metric, "mode") == "idle" { - cpu.idleTime += value - cpu.cores++ - } - } - } -} - -func (b *readerImpl) processMemoryMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics, isMac bool) { - if isMac { - nm.MemoryTotalBytes = sumInt64Metric(metricMap, metricMemoryTotalBytes) - nm.MemoryUsageBytes = sumInt64Metric(metricMap, metricMemoryActiveBytes) + sumInt64Metric(metricMap, metricMemoryWiredBytes) - } else { - nm.MemoryTotalBytes = sumInt64Metric(metricMap, metricMemoryMemTotalBytes) - availableMemory := sumInt64Metric(metricMap, metricMemoryMemAvailableBytes) - nm.MemoryUsageBytes = nm.MemoryTotalBytes - availableMemory - } -} - -func (b *readerImpl) processDiskMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) { - if metric, ok := metricMap[metricFilesystemSizeBytes]; ok { - for _, m := range metric.Metric { - if getLabel(m, "mountpoint") == "/" { - nm.DiskTotalBytes = int64(getMetricValue(m, metric.GetType())) - break - } - } - } - - if metric, ok := metricMap[metricFilesystemAvailBytes]; ok { - for _, m := range metric.Metric { - if getLabel(m, "mountpoint") == "/" { - availableDisk := int64(getMetricValue(m, metric.GetType())) - nm.DiskUsageBytes = nm.DiskTotalBytes - availableDisk - break - } - } - } -} - -func (b *readerImpl) processNetworkMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) { - nm.NetworkReceiveBytesTotal = sumInt64Metric(metricMap, metricNetworkReceiveBytesTotal) - nm.NetworkTransmitBytesTotal = sumInt64Metric(metricMap, metricNetworkTransmitBytesTotal) -} - -func (b *readerImpl) processLoadMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) { - loads := []string{metricLoad1, metricLoad5, metricLoad15} - for _, load := range loads { - value := sumFloat64Metric(metricMap, load) - nm.CpuLoadInfo += fmt.Sprintf("%.2f|", value) - } - nm.CpuLoadInfo = strings.TrimRight(nm.CpuLoadInfo, "|") -} - -func (b *readerImpl) calculateFinalMetrics(nm *NodeMetrics, cpu *cpuStats) { - nm.CpuCoreCount = cpu.cores - if cpu.totalTime > 0 { - nm.CpuUsagePercent = 100 * (cpu.totalTime - cpu.idleTime) / cpu.totalTime - } - if nm.MemoryTotalBytes > 0 { - nm.MemoryUsagePercent = 100 * float64(nm.MemoryUsageBytes) / float64(nm.MemoryTotalBytes) - } - if nm.DiskTotalBytes > 0 { - nm.DiskUsagePercent = 100 * float64(nm.DiskUsageBytes) / float64(nm.DiskTotalBytes) - } - - nm.CpuUsagePercent = math.Round(nm.CpuUsagePercent*100) / 100 - nm.MemoryUsagePercent = math.Round(nm.MemoryUsagePercent*100) / 100 - nm.DiskUsagePercent = math.Round(nm.DiskUsagePercent*100) / 100 - - if b.lastMetrics != nil { - duration := time.Since(b.lastMetrics.SyncTime).Seconds() - if duration > 0.1 { - nm.NetworkReceiveBytesRate = math.Max(0, float64(nm.NetworkReceiveBytesTotal-b.lastMetrics.NetworkReceiveBytesTotal)/duration) - nm.NetworkTransmitBytesRate = math.Max(0, float64(nm.NetworkTransmitBytesTotal-b.lastMetrics.NetworkTransmitBytesTotal)/duration) - nm.NetworkReceiveBytesRate = math.Round(nm.NetworkReceiveBytesRate) - nm.NetworkTransmitBytesRate = math.Round(nm.NetworkTransmitBytesRate) - } - } -} - -func sumInt64Metric(metricMap map[string]*dto.MetricFamily, metricName string) int64 { - ret := int64(0) - if metric, ok := metricMap[metricName]; ok && len(metric.Metric) > 0 { - for _, m := range metric.Metric { - ret += int64(getMetricValue(m, metric.GetType())) - } - } - return ret -} - -func sumFloat64Metric(metricMap map[string]*dto.MetricFamily, metricName string) float64 { - ret := float64(0) - if metric, ok := metricMap[metricName]; ok && len(metric.Metric) > 0 { - for _, m := range metric.Metric { - ret += getMetricValue(m, metric.GetType()) - } - } - return ret -} - -func getLabel(metric *dto.Metric, name string) string { - for _, label := range metric.Label { - if label.GetName() == name { - return label.GetValue() - } - } - return "" -} diff --git a/pkg/metric_reader/reader.go b/pkg/metric_reader/reader.go deleted file mode 100644 index 28618c1d3..000000000 --- a/pkg/metric_reader/reader.go +++ /dev/null @@ -1,84 +0,0 @@ -package metric_reader - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "time" - - dto "github.com/prometheus/client_model/go" - "github.com/prometheus/common/expfmt" - "github.com/prometheus/common/model" - "go.uber.org/zap" -) - -type Reader interface { - ReadOnce(ctx context.Context) (*NodeMetrics, map[string]*RuleMetrics, error) -} - -type readerImpl struct { - metricsURL string - apiToken string // optional bearer token; empty when web auth disabled - httpClient *http.Client - - lastMetrics *NodeMetrics - lastRuleMetrics map[string]*RuleMetrics // key: label value: RuleMetrics - l *zap.SugaredLogger -} - -func NewReader(metricsURL, apiToken string) *readerImpl { - c := &http.Client{Timeout: 30 * time.Second} - return &readerImpl{ - httpClient: c, - metricsURL: metricsURL, - apiToken: apiToken, - l: zap.S().Named("metric_reader"), - } -} - -func (b *readerImpl) ReadOnce(ctx context.Context) (*NodeMetrics, map[string]*RuleMetrics, error) { - metricMap, err := b.fetchMetrics(ctx) - if err != nil { - return nil, nil, fmt.Errorf("failed to fetch metrics: %w", err) - } - nm := &NodeMetrics{SyncTime: time.Now()} - if err := b.ParseNodeMetrics(metricMap, nm); err != nil { - return nil, nil, err - } - - rm := make(map[string]*RuleMetrics) - if err := b.ParseRuleMetrics(metricMap, rm); err != nil { - return nil, nil, err - } - - b.lastMetrics = nm - b.lastRuleMetrics = rm - return nm, rm, nil -} - -func (r *readerImpl) fetchMetrics(ctx context.Context) (map[string]*dto.MetricFamily, error) { - req, err := http.NewRequestWithContext(ctx, "GET", r.metricsURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - if r.apiToken != "" { - req.Header.Set("Authorization", "Bearer "+r.apiToken) - } - - resp, err := r.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - // Use LegacyValidation for backward compatibility with older Prometheus metrics - // This prevents the "Invalid name validation scheme requested: unset" panic - parser := expfmt.NewTextParser(model.LegacyValidation) - return parser.TextToMetricFamilies(strings.NewReader(string(body))) -} diff --git a/pkg/metric_reader/rule.go b/pkg/metric_reader/rule.go deleted file mode 100644 index c9fc1e664..000000000 --- a/pkg/metric_reader/rule.go +++ /dev/null @@ -1,146 +0,0 @@ -package metric_reader - -import ( - "time" - - dto "github.com/prometheus/client_model/go" -) - -const ( - metricConnectionCount = "ehco_traffic_current_connection_count" - metricNetworkTransmit = "ehco_traffic_network_transmit_bytes" - metricPingResponse = "ehco_ping_response_duration_milliseconds" - metricHandshakeDuration = "ehco_traffic_handshake_duration_milliseconds" - - labelKey = "label" - remoteKey = "remote" - connTypeKey = "conn_type" - flowKey = "flow" - ipKey = "ip" -) - -type PingMetric struct { - Latency int64 `json:"latency"` // in ms - Target string `json:"target"` -} - -type RuleMetrics struct { - Label string // rule label - - PingMetrics map[string]*PingMetric // key: remote - - TCPConnectionCount map[string]int64 // key: remote - TCPHandShakeDuration map[string]int64 // key: remote in ms - TCPNetworkTransmitBytes map[string]int64 // key: remote - - UDPConnectionCount map[string]int64 // key: remote - UDPHandShakeDuration map[string]int64 // key: remote in ms - UDPNetworkTransmitBytes map[string]int64 // key: remote - - SyncTime time.Time -} - -func (b *readerImpl) ParseRuleMetrics(metricMap map[string]*dto.MetricFamily, rm map[string]*RuleMetrics) error { - requiredMetrics := []string{ - metricConnectionCount, - metricNetworkTransmit, - metricPingResponse, - metricHandshakeDuration, - } - - for _, metricName := range requiredMetrics { - metricFamily, ok := metricMap[metricName] - if !ok { - continue - } - - for _, metric := range metricFamily.Metric { - labels := getLabelMap(metric) - value := int64(getMetricValue(metric, metricFamily.GetType())) - label, ok := labels[labelKey] - if !ok || label == "" { - continue - } - - ruleMetric := b.ensureRuleMetric(rm, label) - - switch metricName { - case metricConnectionCount: - b.updateConnectionCount(ruleMetric, labels, value) - case metricNetworkTransmit: - b.updateNetworkTransmit(ruleMetric, labels, value) - case metricPingResponse: - b.updatePingMetrics(ruleMetric, labels, value) - case metricHandshakeDuration: - b.updateHandshakeDuration(ruleMetric, labels, value) - } - } - } - return nil -} - -func (b *readerImpl) ensureRuleMetric(rm map[string]*RuleMetrics, label string) *RuleMetrics { - if _, ok := rm[label]; !ok { - rm[label] = &RuleMetrics{ - Label: label, - PingMetrics: make(map[string]*PingMetric), - TCPConnectionCount: make(map[string]int64), - TCPHandShakeDuration: make(map[string]int64), - TCPNetworkTransmitBytes: make(map[string]int64), - UDPConnectionCount: make(map[string]int64), - UDPHandShakeDuration: make(map[string]int64), - UDPNetworkTransmitBytes: make(map[string]int64), - - SyncTime: time.Now(), - } - } - return rm[label] -} - -func (b *readerImpl) updateConnectionCount(rm *RuleMetrics, labels map[string]string, value int64) { - key := labels[remoteKey] - switch labels[connTypeKey] { - case "tcp": - rm.TCPConnectionCount[key] = value - default: - rm.UDPConnectionCount[key] = value - } -} - -func (b *readerImpl) updateNetworkTransmit(rm *RuleMetrics, labels map[string]string, value int64) { - if labels[flowKey] == "read" { - key := labels[remoteKey] - switch labels[connTypeKey] { - case "tcp": - rm.TCPNetworkTransmitBytes[key] += value - default: - rm.UDPNetworkTransmitBytes[key] += value - } - } -} - -func (b *readerImpl) updatePingMetrics(rm *RuleMetrics, labels map[string]string, value int64) { - remote := labels[remoteKey] - rm.PingMetrics[remote] = &PingMetric{ - Latency: value, - Target: labels[ipKey], - } -} - -func (b *readerImpl) updateHandshakeDuration(rm *RuleMetrics, labels map[string]string, value int64) { - key := labels[remoteKey] - switch labels[connTypeKey] { - case "tcp": - rm.TCPHandShakeDuration[key] = value - default: - rm.UDPHandShakeDuration[key] = value - } -} - -func getLabelMap(metric *dto.Metric) map[string]string { - labels := make(map[string]string) - for _, label := range metric.Label { - labels[label.GetName()] = label.GetValue() - } - return labels -} diff --git a/pkg/metric_reader/utils.go b/pkg/metric_reader/utils.go deleted file mode 100644 index 290c2abc2..000000000 --- a/pkg/metric_reader/utils.go +++ /dev/null @@ -1,46 +0,0 @@ -package metric_reader - -import ( - "math" - - dto "github.com/prometheus/client_model/go" -) - -func calculatePercentile(histogram *dto.Histogram, percentile float64) float64 { - if histogram == nil { - return 0 - } - totalSamples := histogram.GetSampleCount() - targetSample := percentile * float64(totalSamples) - cumulativeCount := uint64(0) - var lastBucketBound float64 - - for _, bucket := range histogram.Bucket { - cumulativeCount += bucket.GetCumulativeCount() - if float64(cumulativeCount) >= targetSample { - // Linear interpolation between bucket boundaries - if bucket.GetCumulativeCount() > 0 && lastBucketBound != bucket.GetUpperBound() { - return lastBucketBound + (float64(targetSample-float64(cumulativeCount-bucket.GetCumulativeCount()))/float64(bucket.GetCumulativeCount()))*(bucket.GetUpperBound()-lastBucketBound) - } else { - return bucket.GetUpperBound() - } - } - lastBucketBound = bucket.GetUpperBound() - } - return math.NaN() -} - -func getMetricValue(metric *dto.Metric, metricType dto.MetricType) float64 { - switch metricType { - case dto.MetricType_COUNTER: - return metric.Counter.GetValue() - case dto.MetricType_GAUGE: - return metric.Gauge.GetValue() - case dto.MetricType_HISTOGRAM: - histogram := metric.Histogram - if histogram != nil { - return calculatePercentile(histogram, 0.9) - } - } - return 0 -} diff --git a/pkg/xray/bandwidth_recorder.go b/pkg/xray/bandwidth_recorder.go index b975d640c..43ae90571 100644 --- a/pkg/xray/bandwidth_recorder.go +++ b/pkg/xray/bandwidth_recorder.go @@ -2,104 +2,57 @@ package xray import ( "context" - "io" - "net/http" - "strconv" - "strings" "time" -) -const ( - netWorkSendMetric = "node_network_transmit_bytes_total" - netWorkRecvMetric = "node_network_receive_bytes_total" + psnet "github.com/shirou/gopsutil/v4/net" ) +// bandwidthRecorder samples host network counters via gopsutil and +// reports per-interval bandwidth (bytes/sec) plus the raw byte deltas. +// It replaces the older self-HTTP-scrape against /metrics, which only +// existed because the source (node_exporter) lived in the same process +// as the sink. type bandwidthRecorder struct { - currentSendBytes float64 - uploadBandwidthBytes float64 + currentSendBytes float64 + currentRecvBytes float64 - currentRecvBytes float64 + uploadBandwidthBytes float64 downloadBandwidthBytes float64 lastRecordTime time.Time - - httpClient *http.Client - metricsURL string - apiToken string // optional bearer token; empty when web auth disabled } -func NewBandwidthRecorder(metricsURL, apiToken string) *bandwidthRecorder { - c := &http.Client{Timeout: 30 * time.Second} - return &bandwidthRecorder{ - httpClient: c, - metricsURL: metricsURL, - apiToken: apiToken, - } +func newBandwidthRecorder() *bandwidthRecorder { + return &bandwidthRecorder{} } func (b *bandwidthRecorder) RecordOnce(ctx context.Context) (uploadIncr float64, downloadIncr float64, err error) { - req, err := http.NewRequestWithContext(ctx, "GET", b.metricsURL, nil) - if err != nil { - return - } - if b.apiToken != "" { - req.Header.Set("Authorization", "Bearer "+b.apiToken) - } - response, err := b.httpClient.Do(req) - if err != nil { - return - } - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) + io, err := psnet.IOCountersWithContext(ctx, false) if err != nil { - return + return 0, 0, err } - lines := strings.Split(string(body), "\n") - - var send float64 - var recv float64 - - for _, line := range lines { - if strings.HasPrefix(line, netWorkSendMetric) { - parts := strings.Split(line, " ") - if len(parts) >= 2 { - value := parts[1] - send += parseFloat(value) - } - } - - if strings.HasPrefix(line, netWorkRecvMetric) { - parts := strings.Split(line, " ") - if len(parts) >= 2 { - value := parts[1] - recv += parseFloat(value) - } - } + var send, recv float64 + if len(io) > 0 { + send = float64(io[0].BytesSent) + recv = float64(io[0].BytesRecv) } now := time.Now() if !b.lastRecordTime.IsZero() { - // calculate bandwidth elapsed := now.Sub(b.lastRecordTime).Seconds() - uploadIncr = (send - b.currentSendBytes) - downloadIncr = (recv - b.currentRecvBytes) + uploadIncr = send - b.currentSendBytes + downloadIncr = recv - b.currentRecvBytes if elapsed > 0 { b.uploadBandwidthBytes = uploadIncr / elapsed b.downloadBandwidthBytes = downloadIncr / elapsed } } b.lastRecordTime = now - b.currentRecvBytes = recv b.currentSendBytes = send + b.currentRecvBytes = recv return } -func parseFloat(s string) float64 { - value, _ := strconv.ParseFloat(s, 64) - return value -} - func (b *bandwidthRecorder) GetDownloadBandwidth() float64 { return b.downloadBandwidthBytes } diff --git a/pkg/xray/server.go b/pkg/xray/server.go index 372814177..e3b7cbe0f 100644 --- a/pkg/xray/server.go +++ b/pkg/xray/server.go @@ -157,7 +157,7 @@ func (xs *XrayServer) Setup() error { if len(proxyTags) == 0 { return errors.New("can't find proxy tag in config") } - xs.up = NewUserPool(xs.cfg.SyncTrafficEndPoint, xs.cfg.GetMetricURL(), xs.cfg.ApiToken, proxyTags) + xs.up = NewUserPool(xs.cfg.SyncTrafficEndPoint, proxyTags) xs.up.SetConnTracker(xs.tracker) im, ok := instance.GetFeature(inbound.ManagerType()).(inbound.Manager) diff --git a/pkg/xray/user.go b/pkg/xray/user.go index 2291bdff1..e787e6063 100644 --- a/pkg/xray/user.go +++ b/pkg/xray/user.go @@ -194,17 +194,14 @@ type UserPool struct { remoteConfigURL string } -func NewUserPool(remoteConfigURL, metricURL, apiToken string, proxyTags []string) *UserPool { - up := &UserPool{ +func NewUserPool(remoteConfigURL string, proxyTags []string) *UserPool { + return &UserPool{ l: zap.L().Named("user_pool"), users: make(map[int]*User), proxyTags: proxyTags, remoteConfigURL: remoteConfigURL, + br: newBandwidthRecorder(), } - if metricURL != "" { - up.br = NewBandwidthRecorder(metricURL, apiToken) - } - return up } // SetInboundManager wires the in-process xray inbound.Manager that the pool @@ -358,26 +355,24 @@ func (up *UserPool) syncTrafficToServer(ctx context.Context) error { } req := &SyncTrafficReq{Data: tfs} - if up.br != nil { - // Bandwidth is best-effort: a failed /metrics/ fetch (e.g. web server - // not yet ready at boot, or a transient blip) shouldn't block the user - // traffic upload. Report 0 for this cycle and try again next tick. - uploadIncr, downloadIncr, err := up.br.RecordOnce(ctx) - if err != nil { - up.l.Sugar().Warnf("bandwidth fetch failed (will retry next tick): %v", err) - } else { - ub := up.br.GetUploadBandwidth() - req.UploadBandwidth = int64(ub) - db := up.br.GetDownloadBandwidth() - req.DownloadBandwidth = int64(db) - up.l.Sugar().Debug( - "Upload Bandwidth :", bytes.PrettyByteSize(ub), - "Download Bandwidth :", bytes.PrettyByteSize(db), - "Total Bandwidth :", bytes.PrettyByteSize(ub+db), - "Total Increment By BR", bytes.PrettyByteSize(uploadIncr+downloadIncr), - "Total Increment Per User :", bytes.PrettyByteSize(float64(req.GetTotalTraffic())), - ) - } + // Bandwidth is best-effort: a transient gopsutil error shouldn't + // block the user traffic upload. Report 0 for this cycle and try + // again next tick. + uploadIncr, downloadIncr, err := up.br.RecordOnce(ctx) + if err != nil { + up.l.Sugar().Warnf("bandwidth sample failed (will retry next tick): %v", err) + } else { + ub := up.br.GetUploadBandwidth() + req.UploadBandwidth = int64(ub) + db := up.br.GetDownloadBandwidth() + req.DownloadBandwidth = int64(db) + up.l.Sugar().Debug( + "Upload Bandwidth :", bytes.PrettyByteSize(ub), + "Download Bandwidth :", bytes.PrettyByteSize(db), + "Total Bandwidth :", bytes.PrettyByteSize(ub+db), + "Total Increment By BR", bytes.PrettyByteSize(uploadIncr+downloadIncr), + "Total Increment Per User :", bytes.PrettyByteSize(float64(req.GetTotalTraffic())), + ) } if payload, err := json.Marshal(req); err == nil { up.l.Sugar().Infof("syncTrafficToServer payload: %s", payload) From 1e0e74c65075b471df804f00150b6a1f98a9d074 Mon Sep 17 00:00:00 2001 From: Ehco Date: Tue, 5 May 2026 14:19:37 +0800 Subject: [PATCH 2/2] fix(updater): detect nightly tag republish via published_at vs BuildTime (#454) Nightly uses a rolling tag (v1.1.7-next), so version-string equality made `Check` and `Apply` short-circuit to "already up to date" even after a fresh nightly was published. Both ldflag paths (goreleaser and Makefile) inject BuildTime, so compare release.published_at against the local BuildTime when versions match. Stable channel is unaffected since its tags don't roll. Co-authored-by: Claude Opus 4.7 (1M context) --- internal/cli/update.go | 2 +- internal/updater/updater.go | 52 +++++++++++++++++++++++++++----- internal/updater/updater_test.go | 48 ++++++++++++++++++++++++++++- internal/web/handler_update.go | 4 +-- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/internal/cli/update.go b/internal/cli/update.go index 1388e4321..0a33eff66 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -24,6 +24,6 @@ var UpdateCMD = &cli.Command{ Channel: c.String("channel"), Force: c.Bool("force"), Restart: !c.Bool("no-restart"), - }, constant.Version, cliLogger, nil) + }, constant.Version, constant.BuildTime, cliLogger, nil) }, } diff --git a/internal/updater/updater.go b/internal/updater/updater.go index e48222e73..5c333b40f 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -80,7 +80,8 @@ type ghRelease struct { } // Check resolves channel against currentVersion and queries GitHub. -func Check(ctx context.Context, channel, currentVersion string) (*CheckResult, error) { +// currentBuildTime is the ldflag-injected constant.BuildTime; empty is fine. +func Check(ctx context.Context, channel, currentVersion, currentBuildTime string) (*CheckResult, error) { resolved, rel, err := pickRelease(ctx, channel, currentVersion) if err != nil { return nil, err @@ -96,7 +97,11 @@ func Check(ctx context.Context, channel, currentVersion string) (*CheckResult, e ReleaseURL: rel.HTMLURL, PublishedAt: rel.PublishedAt, } - res.UpdateAvailable = latest != currentVersion && compareVersions(latest, currentVersion) > 0 + if latest == currentVersion { + res.UpdateAvailable = nightlyRepublished(rel, currentBuildTime) + } else { + res.UpdateAvailable = compareVersions(latest, currentVersion) > 0 + } if a := pickAsset(rel.Assets); a != nil { res.AssetName = a.Name res.AssetURL = a.BrowserDownloadURL @@ -106,7 +111,7 @@ func Check(ctx context.Context, channel, currentVersion string) (*CheckResult, e // Apply downloads + swaps + (optionally) restarts. Each phase is reported // to onState so the dashboard can render progress; CLI passes nil. -func Apply(ctx context.Context, opts ApplyOptions, currentVersion string, log *zap.SugaredLogger, onState func(State)) error { +func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentBuildTime string, log *zap.SugaredLogger, onState func(State)) error { emit := func(s State) { if onState != nil { onState(s) @@ -123,11 +128,15 @@ func Apply(ctx context.Context, opts ApplyOptions, currentVersion string, log *z if !opts.Force { if latest == currentVersion { - log.Info("already up to date") - emit(StateDone) - return nil - } - if compareVersions(latest, currentVersion) < 0 { + if nightlyRepublished(rel, currentBuildTime) { + log.Infof("nightly tag %s republished after local build (%s); reinstalling", + rel.TagName, currentBuildTime) + } else { + log.Info("already up to date") + emit(StateDone) + return nil + } + } else if compareVersions(latest, currentVersion) < 0 { return fmt.Errorf("refusing to downgrade %s -> %s; use force", currentVersion, latest) } } @@ -259,6 +268,33 @@ func getJSON(ctx context.Context, url string, out any) error { return json.NewDecoder(resp.Body).Decode(out) } +// nightlyRepublished reports whether a release whose tag matches the +// running version is actually newer than the current binary. Nightly +// uses a rolling tag (v1.1.7-next), so version-string equality alone +// would mask republished builds. Only meaningful for prereleases; stable +// tags don't roll. +func nightlyRepublished(rel *ghRelease, currentBuildTime string) bool { + if !rel.Prerelease || currentBuildTime == "" { + return false + } + built, ok := parseBuildTime(currentBuildTime) + if !ok { + return false + } + return rel.PublishedAt.After(built) +} + +// parseBuildTime accepts both ldflag formats: goreleaser's RFC3339 +// ({{.Date}}) and the Makefile's "2006-01-02-15:04:05". +func parseBuildTime(s string) (time.Time, bool) { + for _, layout := range []string{time.RFC3339, "2006-01-02-15:04:05"} { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + // compareVersions returns -1/0/1 like semver.Compare. Falls back to // string compare for unparseable versions so a malformed constant.Version // never crashes the updater (--force still works). diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 0af3dd06b..46ce7ce8b 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -1,6 +1,9 @@ package updater -import "testing" +import ( + "testing" + "time" +) func TestResolveChannel(t *testing.T) { cases := []struct { @@ -44,3 +47,46 @@ func TestCompareVersions(t *testing.T) { } } } + +func TestParseBuildTime(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"2026-05-04T23:10:16Z", true}, // goreleaser + {"2026-05-04T23:10:16+08:00", true}, // RFC3339 w/ offset + {"2026-05-04-23:10:16", true}, // Makefile + {"", false}, + {"not-a-time", false}, + } + for _, c := range cases { + _, ok := parseBuildTime(c.in) + if ok != c.want { + t.Errorf("parseBuildTime(%q) ok=%v want %v", c.in, ok, c.want) + } + } +} + +func TestNightlyRepublished(t *testing.T) { + built := "2026-05-04T23:10:16Z" + older := time.Date(2026, 5, 4, 22, 0, 0, 0, time.UTC) + newer := time.Date(2026, 5, 5, 6, 0, 0, 0, time.UTC) + + cases := []struct { + name string + rel ghRelease + buildTime string + want bool + }{ + {"prerelease republished after build", ghRelease{Prerelease: true, PublishedAt: newer}, built, true}, + {"prerelease same/older than build", ghRelease{Prerelease: true, PublishedAt: older}, built, false}, + {"stable release ignored", ghRelease{Prerelease: false, PublishedAt: newer}, built, false}, + {"empty build time -> conservative false", ghRelease{Prerelease: true, PublishedAt: newer}, "", false}, + {"unparseable build time -> false", ghRelease{Prerelease: true, PublishedAt: newer}, "garbage", false}, + } + for _, c := range cases { + if got := nightlyRepublished(&c.rel, c.buildTime); got != c.want { + t.Errorf("%s: got %v want %v", c.name, got, c.want) + } + } +} diff --git a/internal/web/handler_update.go b/internal/web/handler_update.go index 19b211862..072fdf9ec 100644 --- a/internal/web/handler_update.go +++ b/internal/web/handler_update.go @@ -54,7 +54,7 @@ func (s *Server) UpdateCheck(c echo.Context) error { } ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second) defer cancel() - res, err := updater.Check(ctx, channel, constant.Version) + res, err := updater.Check(ctx, channel, constant.Version, constant.BuildTime) if err != nil { return echo.NewHTTPError(http.StatusBadGateway, err.Error()) } @@ -105,7 +105,7 @@ func (s *Server) runUpdate(opts updater.ApplyOptions, job *JobStatus) { *job = next } - if err := updater.Apply(ctx, opts, constant.Version, s.l, onState); err != nil { + if err := updater.Apply(ctx, opts, constant.Version, constant.BuildTime, s.l, onState); err != nil { next := *job next.State = updater.StateFailed next.Error = err.Error()