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.
This commit is contained in:
Muse
2026-10-22 21:57:23 +00:00
parent 540c05b0a5
commit e03ceaa41f
11 changed files with 1582 additions and 6 deletions
+41
View File
@@ -511,6 +511,23 @@ func (a *AgentController) confirmAgentUpdate(id uint, reportedVersion string) {
}).Error
}
// heartbeatProbeResult is one latency sample an agent piggybacks on
// its heartbeat (item 7 B4). LatencyMs is a pointer: an absent field
// means "no sample" (a resolution overrun produces none) and the row
// is skipped, while a present negative value is a genuine recorded
// miss — the two must never collapse into each other.
type heartbeatProbeResult struct {
TaskID int64 `json:"taskId"`
LatencyMs *int64 `json:"latencyMs"`
}
// maxProbeResultsPerHeartbeat caps one heartbeat's probe batch. The
// agent's buffer holds far less than this in practice (64 tasks × 60 s
// at the fastest legal interval is under a thousand samples); the cap
// exists so a buggy or hostile reporter cannot make one request file
// an unbounded batch.
const maxProbeResultsPerHeartbeat = 2048
func (a *AgentController) heartbeat(c *gin.Context) {
agent, ok := a.findAgent(bearerToken(c))
if !ok {
@@ -525,6 +542,7 @@ func (a *AgentController) heartbeat(c *gin.Context) {
UpdateStatus *heartbeatUpdateStatus `json:"updateStatus"`
ProbeFacts *heartbeatProbeFacts `json:"probeFacts"`
ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"`
ProbeResults []heartbeatProbeResult `json:"probeResults"`
}
_ = c.ShouldBindJSON(&req)
// The last-seen stamp as it stood before this heartbeat: the probe
@@ -542,6 +560,23 @@ func (a *AgentController) heartbeat(c *gin.Context) {
// probe package logs and swallows its own failures.
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), prevLastSeen)
}
if len(req.ProbeResults) > 0 {
// Item 7 B4: file the latency batch, stamped at arrival (hub
// semantics). Results ride the heartbeat rather than the
// traffic report on purpose: probing must keep working while
// xray is down, and the traffic report early-returns then.
results := make([]probe.PingResult, 0, len(req.ProbeResults))
for _, r := range req.ProbeResults {
if r.TaskID <= 0 || r.LatencyMs == nil {
continue
}
results = append(results, probe.PingResult{TaskID: r.TaskID, LatencyMs: *r.LatencyMs})
if len(results) >= maxProbeResultsPerHeartbeat {
break
}
}
probe.InsertPings(int64(agent.Id), results)
}
if req.Telemetry != nil {
a.updateAgentTelemetry(uint(agent.Id), req.Telemetry)
if req.Telemetry.AgentVersion != nil && *req.Telemetry.AgentVersion != "" {
@@ -581,6 +616,12 @@ func (a *AgentController) getConfig(c *gin.Context) {
payload := gin.H{
"config_hash": hex.EncodeToString(sum[:]),
"xray_config": json.RawMessage(raw),
// Item 7 B4: the agent's probe task set rides the existing
// config poll (no new channel). Always present from this
// master version — an empty list means "run no probes", while
// an absent field (older master) leaves the agent's current
// set untouched.
"probe_tasks": probe.PingTasksFor(int64(agent.Id)),
}
// Item 20: a pending one-click update intent rides along on the config
// poll (no new channel — the agent already polls this endpoint).