chore: 准备发布 v1.0.0 版本
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Easy Proxies</title>
|
||||
<script type="module" crossorigin src="/assets/index-BkdNNtGR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CZdhzSdd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
||||
package monitor
|
||||
|
||||
import "context"
|
||||
|
||||
type contextKey string
|
||||
|
||||
const managerKey contextKey = "easy_proxies.monitor"
|
||||
|
||||
// ContextWith attaches the manager into context so downstream components can reuse it.
|
||||
func ContextWith(ctx context.Context, mgr *Manager) context.Context {
|
||||
if mgr == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, managerKey, mgr)
|
||||
}
|
||||
|
||||
// FromContext extracts a manager if present.
|
||||
func FromContext(ctx context.Context) *Manager {
|
||||
mgr, _ := ctx.Value(managerKey).(*Manager)
|
||||
return mgr
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
M "github.com/sagernet/sing/common/metadata"
|
||||
)
|
||||
|
||||
// Config mirrors user settings needed by the monitoring server.
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Listen string
|
||||
ProbeTarget string
|
||||
Password string
|
||||
ProxyUsername string // 代理池的用户名(用于导出)
|
||||
ProxyPassword string // 代理池的密码(用于导出)
|
||||
ExternalIP string // 外部 IP 地址,用于导出时替换 0.0.0.0
|
||||
SkipCertVerify bool // 全局跳过 SSL 证书验证
|
||||
}
|
||||
|
||||
// NodeInfo is static metadata about a proxy entry.
|
||||
type NodeInfo struct {
|
||||
Tag string `json:"tag"`
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
Mode string `json:"mode"`
|
||||
ListenAddress string `json:"listen_address,omitempty"`
|
||||
Port uint16 `json:"port,omitempty"`
|
||||
Region string `json:"region,omitempty"` // GeoIP region code: "jp", "kr", "us", "hk", "tw", "other"
|
||||
Country string `json:"country,omitempty"` // Full country name from GeoIP
|
||||
}
|
||||
|
||||
// TimelineEvent represents a single usage event for debug tracking.
|
||||
type TimelineEvent struct {
|
||||
Time time.Time `json:"time"`
|
||||
Success bool `json:"success"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
}
|
||||
|
||||
const maxTimelineSize = 20
|
||||
|
||||
// Snapshot is a runtime view of a proxy node.
|
||||
type Snapshot struct {
|
||||
NodeInfo
|
||||
FailureCount int `json:"failure_count"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
Blacklisted bool `json:"blacklisted"`
|
||||
BlacklistedUntil time.Time `json:"blacklisted_until"`
|
||||
ActiveConnections int32 `json:"active_connections"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastFailure time.Time `json:"last_failure,omitempty"`
|
||||
LastSuccess time.Time `json:"last_success,omitempty"`
|
||||
LastProbeLatency time.Duration `json:"last_probe_latency,omitempty"`
|
||||
LastLatencyMs int64 `json:"last_latency_ms"`
|
||||
Available bool `json:"available"`
|
||||
InitialCheckDone bool `json:"initial_check_done"`
|
||||
TotalUpload int64 `json:"total_upload"`
|
||||
TotalDownload int64 `json:"total_download"`
|
||||
UploadSpeed int64 `json:"upload_speed"` // bytes/sec
|
||||
DownloadSpeed int64 `json:"download_speed"` // bytes/sec
|
||||
Timeline []TimelineEvent `json:"timeline,omitempty"`
|
||||
}
|
||||
|
||||
type NodeTrafficSpeed struct {
|
||||
Tag string `json:"tag"`
|
||||
UploadSpeed int64 `json:"upload_speed"` // bytes/sec
|
||||
DownloadSpeed int64 `json:"download_speed"` // bytes/sec
|
||||
TotalUpload int64 `json:"total_upload"`
|
||||
TotalDownload int64 `json:"total_download"`
|
||||
}
|
||||
|
||||
type TrafficSummary struct {
|
||||
NodeCount int `json:"node_count"`
|
||||
TotalUpload int64 `json:"total_upload"`
|
||||
TotalDownload int64 `json:"total_download"`
|
||||
UploadSpeed int64 `json:"upload_speed"` // bytes/sec
|
||||
DownloadSpeed int64 `json:"download_speed"` // bytes/sec
|
||||
Nodes []NodeTrafficSpeed `json:"nodes,omitempty"`
|
||||
SampledAt time.Time `json:"sampled_at"`
|
||||
}
|
||||
|
||||
type probeFunc func(ctx context.Context) (time.Duration, error)
|
||||
type releaseFunc func()
|
||||
|
||||
type EntryHandle struct {
|
||||
ref *entry
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
info NodeInfo
|
||||
failure int
|
||||
success int64
|
||||
timeline []TimelineEvent
|
||||
blacklist bool
|
||||
until time.Time
|
||||
lastError string
|
||||
lastFail time.Time
|
||||
lastOK time.Time
|
||||
lastProbe time.Duration
|
||||
active atomic.Int32
|
||||
totalUpload atomic.Int64
|
||||
totalDownload atomic.Int64
|
||||
uploadSpeed int64
|
||||
downloadSpeed int64
|
||||
lastSpeedUpload int64
|
||||
lastSpeedDown int64
|
||||
lastSpeedAt time.Time
|
||||
probe probeFunc
|
||||
release releaseFunc
|
||||
initialCheckDone bool
|
||||
available bool
|
||||
reloadGen uint64 // generation counter to track active registrations
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Manager aggregates all node states for the UI/API.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
reloadGen uint64 // current reload generation
|
||||
probeDst M.Socksaddr
|
||||
probeReady bool
|
||||
mu sync.RWMutex
|
||||
nodes map[string]*entry
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for logging
|
||||
type Logger interface {
|
||||
Info(args ...any)
|
||||
Warn(args ...any)
|
||||
}
|
||||
|
||||
// NewManager constructs a manager and pre-validates the probe target.
|
||||
func NewManager(cfg Config) (*Manager, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
nodes: make(map[string]*entry),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
if cfg.ProbeTarget != "" {
|
||||
target := cfg.ProbeTarget
|
||||
// Strip URL scheme if present (e.g., "https://www.google.com:443" -> "www.google.com:443")
|
||||
if strings.HasPrefix(target, "https://") {
|
||||
target = strings.TrimPrefix(target, "https://")
|
||||
} else if strings.HasPrefix(target, "http://") {
|
||||
target = strings.TrimPrefix(target, "http://")
|
||||
}
|
||||
// Remove trailing path if present
|
||||
if idx := strings.Index(target, "/"); idx != -1 {
|
||||
target = target[:idx]
|
||||
}
|
||||
host, port, err := net.SplitHostPort(target)
|
||||
if err != nil {
|
||||
// If no port specified, use default based on original scheme
|
||||
if strings.HasPrefix(cfg.ProbeTarget, "https://") {
|
||||
host = target
|
||||
port = "443"
|
||||
} else {
|
||||
host = target
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
parsed := M.ParseSocksaddrHostPort(host, parsePort(port))
|
||||
m.probeDst = parsed
|
||||
m.probeReady = true
|
||||
}
|
||||
go m.startTrafficSpeedSampler()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// SetLogger sets the logger for the manager.
|
||||
func (m *Manager) SetLogger(logger Logger) {
|
||||
m.logger = logger
|
||||
}
|
||||
|
||||
// StartPeriodicHealthCheck starts a background goroutine that periodically checks all nodes.
|
||||
// interval: how often to check (e.g., 30 * time.Second)
|
||||
// timeout: timeout for each probe (e.g., 10 * time.Second)
|
||||
func (m *Manager) StartPeriodicHealthCheck(interval, timeout time.Duration) {
|
||||
if !m.probeReady {
|
||||
if m.logger != nil {
|
||||
m.logger.Warn("probe target not configured, periodic health check disabled")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
// 启动后立即进行一次检查
|
||||
m.probeAllNodes(timeout)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.probeAllNodes(timeout)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.Info("periodic health check started, interval: ", interval)
|
||||
}
|
||||
}
|
||||
|
||||
// probeAllNodes checks all registered nodes concurrently.
|
||||
func (m *Manager) probeAllNodes(timeout time.Duration) {
|
||||
m.mu.RLock()
|
||||
entries := make([]*entry, 0, len(m.nodes))
|
||||
for _, e := range m.nodes {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.Info("starting health check for ", len(entries), " nodes")
|
||||
}
|
||||
|
||||
workerLimit := runtime.NumCPU() * 2
|
||||
if workerLimit < 8 {
|
||||
workerLimit = 8
|
||||
}
|
||||
sem := make(chan struct{}, workerLimit)
|
||||
var wg sync.WaitGroup
|
||||
var availableCount atomic.Int32
|
||||
var failedCount atomic.Int32
|
||||
|
||||
for _, e := range entries {
|
||||
e.mu.RLock()
|
||||
probeFn := e.probe
|
||||
tag := e.info.Tag
|
||||
e.mu.RUnlock()
|
||||
|
||||
if probeFn == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
sem <- struct{}{}
|
||||
wg.Add(1)
|
||||
go func(entry *entry, probe probeFunc, tag string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(m.ctx, timeout)
|
||||
latency, err := probe(ctx)
|
||||
cancel()
|
||||
|
||||
entry.mu.Lock()
|
||||
if err != nil {
|
||||
failedCount.Add(1)
|
||||
entry.lastError = err.Error()
|
||||
entry.lastFail = time.Now()
|
||||
entry.lastProbe = 0
|
||||
entry.available = false
|
||||
entry.initialCheckDone = true
|
||||
} else {
|
||||
availableCount.Add(1)
|
||||
entry.lastOK = time.Now()
|
||||
entry.lastProbe = latency
|
||||
entry.available = true
|
||||
entry.initialCheckDone = true
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
|
||||
if err != nil && m.logger != nil {
|
||||
m.logger.Warn("probe failed for ", tag, ": ", err)
|
||||
}
|
||||
}(e, probeFn, tag)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.Info("health check completed: ", availableCount.Load(), " available, ", failedCount.Load(), " failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the periodic health check.
|
||||
func (m *Manager) Stop() {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) startTrafficSpeedSampler() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
m.sampleTrafficSpeeds(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) sampleTrafficSpeeds(now time.Time) {
|
||||
m.mu.RLock()
|
||||
entries := make([]*entry, 0, len(m.nodes))
|
||||
for _, e := range m.nodes {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
for _, e := range entries {
|
||||
e.updateTrafficSpeed(now)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePort(value string) uint16 {
|
||||
p, err := strconv.Atoi(value)
|
||||
if err != nil || p <= 0 || p > 65535 {
|
||||
return 80
|
||||
}
|
||||
return uint16(p)
|
||||
}
|
||||
|
||||
// BeginReload bumps the generation counter. Nodes registered after this call
|
||||
// will be marked with the new generation. Call SweepStaleNodes after reload
|
||||
// to remove nodes that were not re-registered (disabled/deleted nodes).
|
||||
func (m *Manager) BeginReload() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.reloadGen++
|
||||
}
|
||||
|
||||
// SweepStaleNodes removes nodes that were not re-registered during the current
|
||||
// reload cycle. This preserves monitoring data (latency, success/failure counts)
|
||||
// for nodes that are still active, while cleaning up disabled/removed nodes.
|
||||
func (m *Manager) SweepStaleNodes() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for tag, e := range m.nodes {
|
||||
if e.reloadGen != m.reloadGen {
|
||||
delete(m.nodes, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearNodes removes all registered nodes. Use BeginReload + SweepStaleNodes
|
||||
// for reload scenarios to preserve data for active nodes.
|
||||
func (m *Manager) ClearNodes() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.nodes = make(map[string]*entry)
|
||||
}
|
||||
|
||||
// Register ensures a node is tracked and returns its entry.
|
||||
// If the node already exists, its info is updated but monitoring stats
|
||||
// (latency, success/failure counts, etc.) are preserved.
|
||||
func (m *Manager) Register(info NodeInfo) *EntryHandle {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
e, ok := m.nodes[info.Tag]
|
||||
if !ok {
|
||||
e = &entry{
|
||||
info: info,
|
||||
timeline: make([]TimelineEvent, 0, maxTimelineSize),
|
||||
reloadGen: m.reloadGen,
|
||||
}
|
||||
m.nodes[info.Tag] = e
|
||||
} else {
|
||||
e.info = info
|
||||
e.reloadGen = m.reloadGen
|
||||
}
|
||||
return &EntryHandle{ref: e}
|
||||
}
|
||||
|
||||
// DestinationForProbe exposes the configured destination for health checks.
|
||||
func (m *Manager) DestinationForProbe() (M.Socksaddr, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if !m.probeReady {
|
||||
return M.Socksaddr{}, false
|
||||
}
|
||||
return m.probeDst, true
|
||||
}
|
||||
|
||||
// UpdateProbeTarget dynamically updates the probe destination at runtime.
|
||||
func (m *Manager) UpdateProbeTarget(target string) error {
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip URL scheme if present
|
||||
if strings.HasPrefix(target, "https://") {
|
||||
target = strings.TrimPrefix(target, "https://")
|
||||
} else if strings.HasPrefix(target, "http://") {
|
||||
target = strings.TrimPrefix(target, "http://")
|
||||
}
|
||||
// Remove trailing path if present
|
||||
if idx := strings.Index(target, "/"); idx != -1 {
|
||||
target = target[:idx]
|
||||
}
|
||||
|
||||
host, port, err := net.SplitHostPort(target)
|
||||
if err != nil {
|
||||
// If no port specified, use default port 80
|
||||
host = target
|
||||
port = "80"
|
||||
}
|
||||
|
||||
parsed := M.ParseSocksaddrHostPort(host, parsePort(port))
|
||||
|
||||
m.mu.Lock()
|
||||
m.probeDst = parsed
|
||||
m.probeReady = true
|
||||
m.cfg.ProbeTarget = target
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot returns a sorted copy of current node states.
|
||||
// If onlyAvailable is true, only returns nodes that passed initial health check.
|
||||
func (m *Manager) Snapshot() []Snapshot {
|
||||
return m.SnapshotFiltered(false)
|
||||
}
|
||||
|
||||
// SnapshotFiltered returns a sorted copy of current node states.
|
||||
// If onlyAvailable is true, only returns nodes that passed initial health check.
|
||||
// Nodes that haven't been checked yet are also included (they will be checked on first use).
|
||||
func (m *Manager) SnapshotFiltered(onlyAvailable bool) []Snapshot {
|
||||
m.mu.RLock()
|
||||
list := make([]*entry, 0, len(m.nodes))
|
||||
for _, e := range m.nodes {
|
||||
list = append(list, e)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
snapshots := make([]Snapshot, 0, len(list))
|
||||
for _, e := range list {
|
||||
snap := e.snapshot()
|
||||
// 如果只要可用节点:
|
||||
// - 跳过已完成检查但不可用的节点
|
||||
// - 保留未完成检查的节点(它们会在首次使用时被检查)
|
||||
if onlyAvailable && snap.InitialCheckDone && !snap.Available {
|
||||
continue
|
||||
}
|
||||
snapshots = append(snapshots, snap)
|
||||
}
|
||||
// 按延迟排序(延迟小的在前面,未测试的排在最后)
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
latencyI := snapshots[i].LastLatencyMs
|
||||
latencyJ := snapshots[j].LastLatencyMs
|
||||
// -1 表示未测试,排在最后
|
||||
if latencyI < 0 && latencyJ < 0 {
|
||||
return snapshots[i].Name < snapshots[j].Name // 都未测试时按名称排序
|
||||
}
|
||||
if latencyI < 0 {
|
||||
return false // i 未测试,排在后面
|
||||
}
|
||||
if latencyJ < 0 {
|
||||
return true // j 未测试,i 排在前面
|
||||
}
|
||||
if latencyI == latencyJ {
|
||||
return snapshots[i].Name < snapshots[j].Name // 延迟相同时按名称排序
|
||||
}
|
||||
return latencyI < latencyJ
|
||||
})
|
||||
return snapshots
|
||||
}
|
||||
|
||||
// TrafficSummary returns aggregated traffic totals/speeds and per-node speeds.
|
||||
// includeNodes controls whether per-node details are returned.
|
||||
func (m *Manager) TrafficSummary(includeNodes bool) TrafficSummary {
|
||||
m.mu.RLock()
|
||||
list := make([]*entry, 0, len(m.nodes))
|
||||
for _, e := range m.nodes {
|
||||
list = append(list, e)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
summary := TrafficSummary{
|
||||
NodeCount: len(list),
|
||||
SampledAt: time.Now(),
|
||||
}
|
||||
if includeNodes {
|
||||
summary.Nodes = make([]NodeTrafficSpeed, 0, len(list))
|
||||
}
|
||||
|
||||
for _, e := range list {
|
||||
totalUp := e.totalUpload.Load()
|
||||
totalDown := e.totalDownload.Load()
|
||||
|
||||
e.mu.RLock()
|
||||
upSpeed := e.uploadSpeed
|
||||
downSpeed := e.downloadSpeed
|
||||
tag := e.info.Tag
|
||||
e.mu.RUnlock()
|
||||
|
||||
summary.TotalUpload += totalUp
|
||||
summary.TotalDownload += totalDown
|
||||
summary.UploadSpeed += upSpeed
|
||||
summary.DownloadSpeed += downSpeed
|
||||
|
||||
if includeNodes {
|
||||
summary.Nodes = append(summary.Nodes, NodeTrafficSpeed{
|
||||
Tag: tag,
|
||||
UploadSpeed: upSpeed,
|
||||
DownloadSpeed: downSpeed,
|
||||
TotalUpload: totalUp,
|
||||
TotalDownload: totalDown,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// Probe triggers a manual health check.
|
||||
func (m *Manager) Probe(ctx context.Context, tag string) (time.Duration, error) {
|
||||
e, err := m.entry(tag)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if e.probe == nil {
|
||||
return 0, errors.New("probe not available for this node")
|
||||
}
|
||||
latency, err := e.probe(ctx)
|
||||
if err != nil {
|
||||
// 探测失败:标记节点为不可用,清除旧延迟数据
|
||||
e.mu.Lock()
|
||||
e.available = false
|
||||
e.initialCheckDone = true
|
||||
e.lastProbe = 0 // 清除延迟,前端显示"未测试"
|
||||
e.lastError = err.Error()
|
||||
e.lastFail = time.Now()
|
||||
e.mu.Unlock()
|
||||
return 0, err
|
||||
}
|
||||
// 探测成功:recordSuccessWithLatency 已在 probe 函数内更新 available=true
|
||||
e.recordProbeLatency(latency)
|
||||
return latency, nil
|
||||
}
|
||||
|
||||
// Release clears blacklist state for the given node.
|
||||
func (m *Manager) Release(tag string) error {
|
||||
e, err := m.entry(tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.release == nil {
|
||||
return errors.New("release not available for this node")
|
||||
}
|
||||
e.release()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) entry(tag string) (*entry, error) {
|
||||
m.mu.RLock()
|
||||
e, ok := m.nodes[tag]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("node %s not found", tag)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (e *entry) snapshot() Snapshot {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
latencyMs := int64(-1)
|
||||
if e.lastProbe > 0 {
|
||||
latencyMs = e.lastProbe.Milliseconds()
|
||||
if latencyMs == 0 {
|
||||
latencyMs = 1
|
||||
}
|
||||
}
|
||||
|
||||
var timelineCopy []TimelineEvent
|
||||
if len(e.timeline) > 0 {
|
||||
timelineCopy = make([]TimelineEvent, len(e.timeline))
|
||||
copy(timelineCopy, e.timeline)
|
||||
}
|
||||
|
||||
return Snapshot{
|
||||
NodeInfo: e.info,
|
||||
FailureCount: e.failure,
|
||||
SuccessCount: e.success,
|
||||
Blacklisted: e.blacklist,
|
||||
BlacklistedUntil: e.until,
|
||||
ActiveConnections: e.active.Load(),
|
||||
LastError: e.lastError,
|
||||
LastFailure: e.lastFail,
|
||||
LastSuccess: e.lastOK,
|
||||
LastProbeLatency: e.lastProbe,
|
||||
LastLatencyMs: latencyMs,
|
||||
Available: e.available,
|
||||
InitialCheckDone: e.initialCheckDone,
|
||||
TotalUpload: e.totalUpload.Load(),
|
||||
TotalDownload: e.totalDownload.Load(),
|
||||
UploadSpeed: e.uploadSpeed,
|
||||
DownloadSpeed: e.downloadSpeed,
|
||||
Timeline: timelineCopy,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *entry) recordFailure(err error, destination string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
errStr := err.Error()
|
||||
e.failure++
|
||||
e.lastError = errStr
|
||||
e.lastFail = time.Now()
|
||||
// 注意:不修改 available/initialCheckDone
|
||||
// 流量失败不代表节点不可用(可能是目标网站的问题)
|
||||
// available 只由探测操作控制
|
||||
e.appendTimelineLocked(false, 0, errStr, destination)
|
||||
}
|
||||
|
||||
func (e *entry) recordSuccess(destination string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.success++
|
||||
e.lastOK = time.Now()
|
||||
// 注意:不修改 available/initialCheckDone
|
||||
// 流量成功不代表需要更新探测状态
|
||||
// available 只由探测操作控制
|
||||
e.appendTimelineLocked(true, 0, "", destination)
|
||||
}
|
||||
|
||||
func (e *entry) recordSuccessWithLatency(latency time.Duration) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.success++
|
||||
e.lastOK = time.Now()
|
||||
e.lastProbe = latency
|
||||
e.available = true
|
||||
e.initialCheckDone = true
|
||||
latencyMs := latency.Milliseconds()
|
||||
if latencyMs == 0 && latency > 0 {
|
||||
latencyMs = 1
|
||||
}
|
||||
e.appendTimelineLocked(true, latencyMs, "", "")
|
||||
}
|
||||
|
||||
func (e *entry) appendTimelineLocked(success bool, latencyMs int64, errStr string, destination string) {
|
||||
evt := TimelineEvent{
|
||||
Time: time.Now(),
|
||||
Success: success,
|
||||
LatencyMs: latencyMs,
|
||||
Error: errStr,
|
||||
Destination: destination,
|
||||
}
|
||||
if len(e.timeline) >= maxTimelineSize {
|
||||
copy(e.timeline, e.timeline[1:])
|
||||
e.timeline[len(e.timeline)-1] = evt
|
||||
} else {
|
||||
e.timeline = append(e.timeline, evt)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *entry) blacklistUntil(until time.Time) {
|
||||
e.mu.Lock()
|
||||
e.blacklist = true
|
||||
e.until = until
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *entry) clearBlacklist() {
|
||||
e.mu.Lock()
|
||||
e.blacklist = false
|
||||
e.until = time.Time{}
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *entry) incActive() {
|
||||
e.active.Add(1)
|
||||
}
|
||||
|
||||
func (e *entry) decActive() {
|
||||
e.active.Add(-1)
|
||||
}
|
||||
|
||||
func (e *entry) setProbe(fn probeFunc) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.probe = fn
|
||||
}
|
||||
|
||||
func (e *entry) setRelease(fn releaseFunc) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.release = fn
|
||||
}
|
||||
|
||||
func (e *entry) recordProbeLatency(d time.Duration) {
|
||||
e.mu.Lock()
|
||||
e.lastProbe = d
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
func (e *entry) updateTrafficSpeed(now time.Time) {
|
||||
curUp := e.totalUpload.Load()
|
||||
curDown := e.totalDownload.Load()
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if e.lastSpeedAt.IsZero() {
|
||||
e.lastSpeedAt = now
|
||||
e.lastSpeedUpload = curUp
|
||||
e.lastSpeedDown = curDown
|
||||
e.uploadSpeed = 0
|
||||
e.downloadSpeed = 0
|
||||
return
|
||||
}
|
||||
|
||||
elapsed := now.Sub(e.lastSpeedAt).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
deltaUp := curUp - e.lastSpeedUpload
|
||||
deltaDown := curDown - e.lastSpeedDown
|
||||
if deltaUp < 0 {
|
||||
deltaUp = 0
|
||||
}
|
||||
if deltaDown < 0 {
|
||||
deltaDown = 0
|
||||
}
|
||||
|
||||
e.uploadSpeed = int64(float64(deltaUp) / elapsed)
|
||||
e.downloadSpeed = int64(float64(deltaDown) / elapsed)
|
||||
e.lastSpeedUpload = curUp
|
||||
e.lastSpeedDown = curDown
|
||||
e.lastSpeedAt = now
|
||||
}
|
||||
|
||||
// RecordFailure updates failure counters with destination info.
|
||||
func (h *EntryHandle) RecordFailure(err error, destination string) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.recordFailure(err, destination)
|
||||
}
|
||||
|
||||
// RecordSuccess updates the last success timestamp with destination info.
|
||||
func (h *EntryHandle) RecordSuccess(destination string) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.recordSuccess(destination)
|
||||
}
|
||||
|
||||
// RecordSuccessWithLatency updates the last success timestamp and latency.
|
||||
func (h *EntryHandle) RecordSuccessWithLatency(latency time.Duration) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.recordSuccessWithLatency(latency)
|
||||
}
|
||||
|
||||
// Blacklist marks the node unavailable until the given deadline.
|
||||
func (h *EntryHandle) Blacklist(until time.Time) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.blacklistUntil(until)
|
||||
}
|
||||
|
||||
// ClearBlacklist removes the blacklist flag.
|
||||
func (h *EntryHandle) ClearBlacklist() {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.clearBlacklist()
|
||||
}
|
||||
|
||||
// IncActive increments the active connection counter.
|
||||
func (h *EntryHandle) IncActive() {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.incActive()
|
||||
}
|
||||
|
||||
// DecActive decrements the active connection counter.
|
||||
func (h *EntryHandle) DecActive() {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.decActive()
|
||||
}
|
||||
|
||||
// SetProbe assigns a probe function.
|
||||
func (h *EntryHandle) SetProbe(fn func(ctx context.Context) (time.Duration, error)) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.setProbe(fn)
|
||||
}
|
||||
|
||||
// SetRelease assigns a release function.
|
||||
func (h *EntryHandle) SetRelease(fn func()) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.setRelease(fn)
|
||||
}
|
||||
|
||||
// MarkInitialCheckDone marks the initial health check as completed.
|
||||
func (h *EntryHandle) MarkInitialCheckDone(available bool) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.mu.Lock()
|
||||
h.ref.initialCheckDone = true
|
||||
h.ref.available = available
|
||||
h.ref.mu.Unlock()
|
||||
}
|
||||
|
||||
// MarkAvailable updates the availability status.
|
||||
func (h *EntryHandle) MarkAvailable(available bool) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.mu.Lock()
|
||||
h.ref.available = available
|
||||
h.ref.mu.Unlock()
|
||||
}
|
||||
|
||||
// AddTraffic adds upload and download byte counts to the node's traffic counters.
|
||||
func (h *EntryHandle) AddTraffic(upload, download int64) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
if upload > 0 {
|
||||
h.ref.totalUpload.Add(upload)
|
||||
}
|
||||
if download > 0 {
|
||||
h.ref.totalDownload.Add(download)
|
||||
}
|
||||
}
|
||||
|
||||
// SetTraffic sets the traffic counters to specific values (used for restoring from store).
|
||||
func (h *EntryHandle) SetTraffic(upload, download int64) {
|
||||
if h == nil || h.ref == nil {
|
||||
return
|
||||
}
|
||||
h.ref.totalUpload.Store(upload)
|
||||
h.ref.totalDownload.Store(download)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user