Files
3x-ui-agent/internal/agent/ping.go
T
Muse e03ceaa41f B4: probe task store, panel API, heartbeat ingestion, agent prober
- probe package: probe_ping_task/probe_ping_node tables; task CRUD
  with hub save semantics (id high-water, base-diff assignment,
  64-per-agent crowding refusal, auto_join cap); filing with
  assignment-gated insert; PingHistory weighted-median chart read
  over minute+hour tiers; auto-join on first Record (ensureRows).
- controller: /panel/api/agent/probe-tasks* management endpoints
  (node id space, hub validation chain verbatim); series=ping now
  serves real rows from probe.PingHistory; heartbeat ingests
  probeResults (missing latency skipped, batch capped); config
  response carries probe_tasks (always present, empty = clear).
- agent: per-task probe loops (immediate first probe, ticker,
  900ms resolve/dial budgets, <=3 addresses, resolve overrun = no
  sample), result buffer drained on heartbeat and requeued on
  failure; task set applied before the config-hash early return.
2026-10-22 21:57:23 +00:00

268 lines
7.7 KiB
Go

package agent
import (
"context"
"net"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// Item 7 B4: latency probes, the agent side. The master pushes the
// probe task set on the config poll (probe_tasks); the agent runs one
// loop per task — resolve, TCP-connect, time the handshake — and the
// samples ride the next heartbeat back (probeResults). A port of
// monitor-probe's agent prober (agent/src/main.rs tcp_probe and its
// per-task scheduler), with the same numbers: at most 64 tasks, a
// 900 ms budget each for resolution and for every dial, at most three
// addresses tried, and a resolution overrun producing NO sample at
// all rather than a recorded miss.
// ProbeTask is one probe target as pushed by the master.
type ProbeTask struct {
ID int64 `json:"id"`
Target string `json:"target"`
Interval int64 `json:"interval"`
}
// ProbeResult is one filed sample: the task and its handshake latency
// in milliseconds; negative means the target did not answer.
type ProbeResult struct {
TaskID int64 `json:"taskId"`
LatencyMs int64 `json:"latencyMs"`
}
const (
// maxProbeTasksRun mirrors the master's per-agent assignment cap
// (probe.MaxProbesPerNode); an over-long list is truncated, never
// run whole, with the loss logged once per apply.
maxProbeTasksRun = 64
// probeDialBudget bounds name resolution and every single dial.
probeDialBudget = 900 * time.Millisecond
// probeMaxAddrs caps how many resolved addresses one probe tries.
probeMaxAddrs = 3
// probeResultCap bounds the unsent-sample buffer; past it the
// oldest samples drop (they describe a master outage, and their
// timestamps would be discarded on arrival anyway).
probeResultCap = 8192
// Probe intervals are clamped into the master's legal range on
// arrival as a backstop (the panel refuses out-of-range values).
minProbeIntervalSec = 5
maxProbeIntervalSec = 3600
)
type runningProbe struct {
target string
interval int64
cancel context.CancelFunc
}
// probeRunner owns the running probe loops and the result buffer.
// All state is behind mu; probe loops only touch it through methods.
type probeRunner struct {
mu sync.Mutex
tasks map[int64]*runningProbe
results []ProbeResult
dropped int64
resolveLogged map[int64]bool
stopped bool
}
func newProbeRunner() *probeRunner {
return &probeRunner{
tasks: map[int64]*runningProbe{},
resolveLogged: map[int64]bool{},
}
}
// clampProbeInterval folds an interval into the legal range.
func clampProbeInterval(sec int64) int64 {
if sec < minProbeIntervalSec {
return minProbeIntervalSec
}
if sec > maxProbeIntervalSec {
return maxProbeIntervalSec
}
return sec
}
// applyTasks reconciles the running loops with a freshly pushed set:
// a task whose (target, interval) is unchanged keeps its loop and its
// phase; anything else is cancelled and, if still wanted, restarted.
func (r *probeRunner) applyTasks(tasks []ProbeTask) {
if len(tasks) > maxProbeTasksRun {
logger.Warningf("probe: master pushed %d tasks, running only the first %d", len(tasks), maxProbeTasksRun)
tasks = tasks[:maxProbeTasksRun]
}
want := make(map[int64]ProbeTask, len(tasks))
for _, t := range tasks {
t.Interval = clampProbeInterval(t.Interval)
want[t.ID] = t
}
r.mu.Lock()
defer r.mu.Unlock()
if r.stopped {
return
}
for id, running := range r.tasks {
t, ok := want[id]
if !ok || t.Target != running.target || t.Interval != running.interval {
running.cancel()
delete(r.tasks, id)
}
}
for id, t := range want {
if _, ok := r.tasks[id]; ok {
continue
}
ctx, cancel := context.WithCancel(context.Background())
r.tasks[id] = &runningProbe{target: t.Target, interval: t.Interval, cancel: cancel}
go r.runLoop(ctx, t)
}
}
// stop cancels every probe loop (agent shutdown).
func (r *probeRunner) stop() {
r.mu.Lock()
defer r.mu.Unlock()
r.stopped = true
for id, running := range r.tasks {
running.cancel()
delete(r.tasks, id)
}
}
// runLoop probes immediately, then on the task's interval. A tick
// that arrives while a probe is still running is dropped rather than
// queued (the hub's MissedTickBehavior::Delay; a Go ticker behaves
// the same way).
func (r *probeRunner) runLoop(ctx context.Context, task ProbeTask) {
r.probeOnce(task)
ticker := time.NewTicker(time.Duration(task.Interval) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.probeOnce(task)
}
}
}
func (r *probeRunner) probeOnce(task ProbeTask) {
ms, sampled := probeTCP(task.Target, func() {
r.noteResolveTimeout(task.ID, task.Target)
})
if !sampled {
return
}
r.addResult(ProbeResult{TaskID: task.ID, LatencyMs: ms})
}
// noteResolveTimeout logs a skipped round once per task per process:
// a target whose name stops resolving would otherwise be silent
// forever (nothing is filed for it), and one line per interval per
// target would flood the log on a DNS outage (hub discipline).
func (r *probeRunner) noteResolveTimeout(taskID int64, target string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.resolveLogged[taskID] {
return
}
r.resolveLogged[taskID] = true
logger.Warningf("probe: resolving %s overran %s; round skipped (further rounds log nothing)", target, probeDialBudget)
}
func (r *probeRunner) addResult(res ProbeResult) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.results) >= probeResultCap {
r.results = r.results[1:]
r.dropped++
}
r.results = append(r.results, res)
}
// drainResults takes the buffered samples for one heartbeat. The
// caller requeues them when the heartbeat fails, so a master outage
// loses nothing that still fits the buffer.
func (r *probeRunner) drainResults() []ProbeResult {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.results) == 0 {
return nil
}
out := r.results
r.results = nil
return out
}
// requeueResults puts a failed heartbeat's batch back ahead of
// anything probed since, trimming the oldest if the buffer overflows.
func (r *probeRunner) requeueResults(batch []ProbeResult) {
if len(batch) == 0 {
return
}
r.mu.Lock()
defer r.mu.Unlock()
merged := make([]ProbeResult, 0, len(batch)+len(r.results))
merged = append(merged, batch...)
merged = append(merged, r.results...)
if len(merged) > probeResultCap {
merged = merged[len(merged)-probeResultCap:]
}
r.results = merged
}
// probeTCP measures one target's TCP handshake latency in
// milliseconds. It returns (ms, true) for a sample to file — the
// handshake time, or -1 when the target did not answer — and
// (0, false) when the round produced no sample: name resolution
// overran its budget, which the hub treats as "nothing happened"
// rather than a miss, because filing it would blame the target for
// the resolver's state. A malformed target is -1: it is re-probed
// every interval and reads as total loss, which is what it is.
func probeTCP(target string, onResolveTimeout func()) (int64, bool) {
host, port, err := net.SplitHostPort(target)
if err != nil {
return -1, true
}
rctx, cancel := context.WithTimeout(context.Background(), probeDialBudget)
addrs, err := (&net.Resolver{}).LookupIPAddr(rctx, host)
timedOut := rctx.Err() == context.DeadlineExceeded
cancel()
if err != nil {
if timedOut {
if onResolveTimeout != nil {
onResolveTimeout()
}
return 0, false
}
return -1, true
}
if len(addrs) == 0 {
return -1, true
}
dialer := &net.Dialer{}
tried := 0
for _, addr := range addrs {
if tried >= probeMaxAddrs {
break
}
tried++
dctx, dcancel := context.WithTimeout(context.Background(), probeDialBudget)
start := time.Now()
conn, derr := dialer.DialContext(dctx, "tcp", net.JoinHostPort(addr.IP.String(), port))
elapsed := time.Since(start)
dcancel()
if derr == nil {
conn.Close()
return elapsed.Milliseconds(), true
}
}
return -1, true
}