- 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.
334 lines
10 KiB
Go
334 lines
10 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// Item 7 B4: probe task management — the operator-maintained latency
|
|
// target set (ITEM7-SCOPE §5.7, user ruling Q4: no preset list; the
|
|
// operator keeps targets, grouped by the free-form region label, from
|
|
// the panel). Endpoints ride the panel-authenticated agent admin
|
|
// group like B3's probe-meta, and validation mirrors hub api.rs's
|
|
// save_ping_task chain refusal for refusal — including refusing (not
|
|
// clamping) an out-of-range interval.
|
|
//
|
|
// The panel-facing id space is the panel NODE id, as on the
|
|
// probe-meta surface; assignments are stored against agent ids and
|
|
// translated at this layer, where the binding lives.
|
|
|
|
type ProbeTasksController struct{}
|
|
|
|
func NewProbeTasksController(g *gin.RouterGroup) *ProbeTasksController {
|
|
pc := &ProbeTasksController{}
|
|
g.GET("/probe-tasks", pc.listTasks)
|
|
g.PUT("/probe-tasks", pc.saveTask)
|
|
g.DELETE("/probe-tasks/:id", pc.deleteTask)
|
|
g.POST("/probe-tasks/reorder", pc.reorderTasks)
|
|
g.PUT("/probe-tasks/by-node/:nodeId", pc.setNodeTasks)
|
|
return pc
|
|
}
|
|
|
|
// probeTaskForm is the wire shape of one task as the panel edits it.
|
|
type probeTaskForm struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Target string `json:"target"`
|
|
Interval int64 `json:"interval"`
|
|
AutoJoin bool `json:"autoJoin"`
|
|
Sort int64 `json:"sort"`
|
|
Region string `json:"region"`
|
|
NodeIDs []int64 `json:"nodeIds"`
|
|
BaseNodeIDs *[]int64 `json:"baseNodeIds"`
|
|
}
|
|
|
|
// agentsByNode maps panel node id → agent row for every bound agent.
|
|
func agentsByNode() (map[int64]model.AgentNode, error) {
|
|
var agents []model.AgentNode
|
|
if err := database.GetDB().Where("node_id IS NOT NULL").Find(&agents).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[int64]model.AgentNode, len(agents))
|
|
for _, ag := range agents {
|
|
out[int64(*ag.NodeID)] = ag
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// agentDisplayName renders an agent for operator-facing messages: the
|
|
// bound node's name where there is one (the panel identifies nodes by
|
|
// name and never shows an id, as the hub does).
|
|
func agentDisplayName(agentID int64) string {
|
|
var ag model.AgentNode
|
|
if err := database.GetDB().Where("id = ?", agentID).First(&ag).Error; err == nil {
|
|
if ag.NodeID != nil {
|
|
var node model.Node
|
|
if err := database.GetDB().Where("id = ?", *ag.NodeID).First(&node).Error; err == nil && node.Name != "" {
|
|
return node.Name
|
|
}
|
|
}
|
|
if ag.Name != "" {
|
|
return ag.Name
|
|
}
|
|
}
|
|
return fmt.Sprintf("#%d", agentID)
|
|
}
|
|
|
|
// nodeName renders a panel node for messages, "" when it is gone.
|
|
func nodeName(nodeID int64) string {
|
|
var node model.Node
|
|
if err := database.GetDB().Where("id = ?", nodeID).First(&node).Error; err != nil {
|
|
return ""
|
|
}
|
|
return node.Name
|
|
}
|
|
|
|
// resolveAgentIDs translates the form's node ids into agent ids,
|
|
// refusing the whole save when one names a node that is gone or has
|
|
// no agent bound (the hub's foreign key does this by construction;
|
|
// the naming keeps the refusal readable).
|
|
func resolveAgentIDs(nodeIDs []int64, bound map[int64]model.AgentNode) ([]int64, string) {
|
|
out := make([]int64, 0, len(nodeIDs))
|
|
for _, nodeID := range nodeIDs {
|
|
ag, ok := bound[nodeID]
|
|
if !ok {
|
|
if name := nodeName(nodeID); name != "" {
|
|
return nil, fmt.Sprintf("节点「%s」未绑定 agent,无法指派探测任务", name)
|
|
}
|
|
return nil, fmt.Sprintf("节点 %d 不存在,可能已被删除", nodeID)
|
|
}
|
|
out = append(out, int64(ag.Id))
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
func (pc *ProbeTasksController) listTasks(c *gin.Context) {
|
|
tasks, err := probe.PingTasks()
|
|
if err != nil {
|
|
jsonMsg(c, "load probe tasks failed", err)
|
|
return
|
|
}
|
|
bound, err := agentsByNode()
|
|
if err != nil {
|
|
jsonMsg(c, "load probe tasks failed", err)
|
|
return
|
|
}
|
|
nodeByAgent := make(map[int64]int64, len(bound))
|
|
for nodeID, ag := range bound {
|
|
nodeByAgent[int64(ag.Id)] = nodeID
|
|
}
|
|
forms := make([]probeTaskForm, 0, len(tasks))
|
|
for _, t := range tasks {
|
|
f := probeTaskForm{
|
|
ID: t.ID, Name: t.Name, Target: t.Target, Interval: t.Interval,
|
|
AutoJoin: t.AutoJoin, Sort: t.Sort, Region: t.Region, NodeIDs: []int64{},
|
|
}
|
|
for _, agentID := range t.AgentIDs {
|
|
if nodeID, ok := nodeByAgent[agentID]; ok {
|
|
f.NodeIDs = append(f.NodeIDs, nodeID)
|
|
}
|
|
}
|
|
forms = append(forms, f)
|
|
}
|
|
jsonObj(c, forms, nil)
|
|
}
|
|
|
|
// validProbeTarget mirrors hub valid_target: a target the agent can
|
|
// resolve — host:port, an IPv6 literal bracketed as a URL writes one.
|
|
// A bare contains(':') would admit a bare IPv6 address, ":443" with no
|
|
// host and "host:" with no port, each of which probes at 100% loss
|
|
// forever with nothing identifying the target as the cause.
|
|
func validProbeTarget(target string) bool {
|
|
var host, port string
|
|
if rest, ok := strings.CutPrefix(target, "["); ok {
|
|
h, p, found := strings.Cut(rest, "]:")
|
|
if !found {
|
|
return false
|
|
}
|
|
host, port = h, p
|
|
} else {
|
|
// rsplit_once(':'): host:port splits on the last colon.
|
|
i := strings.LastIndex(target, ":")
|
|
if i < 0 {
|
|
return false
|
|
}
|
|
host, port = target[:i], target[i+1:]
|
|
}
|
|
if host == "" || strings.Contains(host, ":") && !strings.HasPrefix(target, "[") {
|
|
return false
|
|
}
|
|
n, err := strconv.Atoi(port)
|
|
return err == nil && n > 0 && n <= 65535
|
|
}
|
|
|
|
// validateProbeTaskForm normalizes the form in place (hub order:
|
|
// trim, presence, name length, target shape, interval range) and
|
|
// returns the first refusal's message, "" when it may be stored. The
|
|
// region rule is ours (the hub has no region): the meta group rule.
|
|
func validateProbeTaskForm(f *probeTaskForm) string {
|
|
// Trimmed into the stored value rather than a discarded copy:
|
|
// what reaches the agent is the stored target, and a trailing
|
|
// space from a paste would leave the probe reporting loss
|
|
// indefinitely (hub discipline, same for the name).
|
|
f.Name = strings.TrimSpace(f.Name)
|
|
f.Target = strings.TrimSpace(f.Target)
|
|
f.Region = strings.TrimSpace(f.Region)
|
|
if f.Name == "" || f.Target == "" {
|
|
return "请填写名称和目标"
|
|
}
|
|
if len([]rune(f.Name)) > 64 {
|
|
return "名称最多 64 个字"
|
|
}
|
|
if !validProbeTarget(f.Target) {
|
|
return "目标要写成「主机:端口」,例如 1.1.1.1:443 或 [2606:4700:4700::1111]:443"
|
|
}
|
|
// Refused rather than clamped: the agent clamps again on
|
|
// arrival, so an out-of-range value would silently become a
|
|
// different number while the panel still displays what was
|
|
// entered (hub).
|
|
if f.Interval < probe.MinProbeInterval || f.Interval > 3600 {
|
|
return fmt.Sprintf("间隔要在 %d 到 3600 秒之间", probe.MinProbeInterval)
|
|
}
|
|
if len([]rune(f.Region)) > 13 || hasControl(f.Region) {
|
|
return "地区最多 13 个字,不能含控制字符"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// probeTaskError renders a store refusal in the hub's words.
|
|
func probeTaskError(c *gin.Context, err error) {
|
|
var crowded *probe.PingCrowdedError
|
|
var autoJoin *probe.PingAutoJoinError
|
|
switch {
|
|
case errors.Is(err, probe.ErrPingTaskGone):
|
|
jsonMsg(c, err.Error(), errRequest)
|
|
case errors.As(err, &crowded):
|
|
jsonMsg(c, fmt.Sprintf("节点「%s」会被分配超过 %d 个探测任务,agent 最多只跑这么多,多出来的会被静默丢掉",
|
|
agentDisplayName(crowded.AgentID), probe.MaxProbesPerNode), errRequest)
|
|
case errors.As(err, &autoJoin):
|
|
jsonMsg(c, fmt.Sprintf("新节点会自动加入 %d 个探测任务,agent 最多只跑 %d 个",
|
|
autoJoin.Count, probe.MaxProbesPerNode), errRequest)
|
|
default:
|
|
jsonMsg(c, err.Error(), errRequest)
|
|
}
|
|
}
|
|
|
|
func (pc *ProbeTasksController) saveTask(c *gin.Context) {
|
|
var form probeTaskForm
|
|
if err := c.ShouldBindJSON(&form); err != nil {
|
|
jsonMsg(c, "Invalid request", err)
|
|
return
|
|
}
|
|
if form.NodeIDs == nil {
|
|
form.NodeIDs = []int64{}
|
|
}
|
|
if msg := validateProbeTaskForm(&form); msg != "" {
|
|
jsonMsg(c, msg, errRequest)
|
|
return
|
|
}
|
|
bound, err := agentsByNode()
|
|
if err != nil {
|
|
jsonMsg(c, "save probe task failed", err)
|
|
return
|
|
}
|
|
agentIDs, refusal := resolveAgentIDs(form.NodeIDs, bound)
|
|
if refusal != "" {
|
|
jsonMsg(c, refusal, errRequest)
|
|
return
|
|
}
|
|
var base *[]int64
|
|
if form.BaseNodeIDs != nil {
|
|
baseIDs, refusal := resolveAgentIDs(*form.BaseNodeIDs, bound)
|
|
if refusal != "" {
|
|
jsonMsg(c, refusal, errRequest)
|
|
return
|
|
}
|
|
base = &baseIDs
|
|
}
|
|
id, err := probe.SavePingTask(probe.PingTask{
|
|
ID: form.ID,
|
|
Name: form.Name,
|
|
Target: form.Target,
|
|
Interval: form.Interval,
|
|
AutoJoin: form.AutoJoin,
|
|
Region: form.Region,
|
|
AgentIDs: agentIDs,
|
|
}, base)
|
|
if err != nil {
|
|
probeTaskError(c, err)
|
|
return
|
|
}
|
|
jsonObj(c, gin.H{"id": id}, nil)
|
|
}
|
|
|
|
func (pc *ProbeTasksController) deleteTask(c *gin.Context) {
|
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
jsonMsg(c, "Invalid request", err)
|
|
return
|
|
}
|
|
if err := probe.DeletePingTask(id); err != nil {
|
|
jsonMsg(c, "delete probe task failed", err)
|
|
return
|
|
}
|
|
jsonObj(c, gin.H{"message": "ok"}, nil)
|
|
}
|
|
|
|
func (pc *ProbeTasksController) reorderTasks(c *gin.Context) {
|
|
var form struct {
|
|
IDs []int64 `json:"ids"`
|
|
}
|
|
if err := c.ShouldBindJSON(&form); err != nil {
|
|
jsonMsg(c, "Invalid request", err)
|
|
return
|
|
}
|
|
if err := probe.ReorderPingTasks(form.IDs); err != nil {
|
|
jsonMsg(c, err.Error(), errRequest)
|
|
return
|
|
}
|
|
jsonObj(c, gin.H{"message": "ok"}, nil)
|
|
}
|
|
|
|
// setNodeTasks edits one node's probe assignments from the node's
|
|
// side (hub set_node_ping_tasks): tasks/base are task ids, only the
|
|
// difference from base is applied.
|
|
func (pc *ProbeTasksController) setNodeTasks(c *gin.Context) {
|
|
nodeID, err := strconv.Atoi(c.Param("nodeId"))
|
|
if err != nil {
|
|
c.JSON(404, gin.H{"message": "agent not found"})
|
|
return
|
|
}
|
|
var agent model.AgentNode
|
|
if err := database.GetDB().Where("node_id = ?", nodeID).First(&agent).Error; err != nil {
|
|
c.JSON(404, gin.H{"message": "agent not found"})
|
|
return
|
|
}
|
|
var form struct {
|
|
Tasks []int64 `json:"tasks"`
|
|
Base []int64 `json:"base"`
|
|
}
|
|
if err := c.ShouldBindJSON(&form); err != nil {
|
|
jsonMsg(c, "监控列表格式不对", errRequest)
|
|
return
|
|
}
|
|
if form.Tasks == nil {
|
|
form.Tasks = []int64{}
|
|
}
|
|
if form.Base == nil {
|
|
form.Base = []int64{}
|
|
}
|
|
if err := probe.SetNodePingTasks(int64(agent.Id), form.Tasks, form.Base); err != nil {
|
|
probeTaskError(c, err)
|
|
return
|
|
}
|
|
jsonObj(c, gin.H{"message": "ok"}, nil)
|
|
}
|