238 lines
5.0 KiB
Go
238 lines
5.0 KiB
Go
package metrics
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/prism/proxy/internal/cache"
|
|
"github.com/prism/proxy/internal/mihomoapi"
|
|
"github.com/prism/proxy/internal/store"
|
|
)
|
|
|
|
type Collector struct {
|
|
store *store.Store
|
|
cache *cache.Cache
|
|
apiBase string
|
|
secret string
|
|
client *http.Client
|
|
|
|
mu sync.Mutex
|
|
connCache []map[string]any
|
|
connCacheAt time.Time
|
|
refreshing bool
|
|
}
|
|
|
|
func NewCollector(s *store.Store, c *cache.Cache, apiBase, secret string) *Collector {
|
|
return &Collector{
|
|
store: s,
|
|
cache: c,
|
|
apiBase: trimRight(apiBase, "/"),
|
|
secret: secret,
|
|
client: &http.Client{
|
|
Timeout: 3 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func trimRight(s, cut string) string {
|
|
for len(s) > 0 && len(s) >= len(cut) && s[len(s)-len(cut):] == cut {
|
|
s = s[:len(s)-len(cut)]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Snapshot returns cached dashboard data and never blocks on Mihomo.
|
|
func (m *Collector) Snapshot(ctx context.Context) cache.DashboardSnapshot {
|
|
snap := m.cache.GetDashboard()
|
|
total, alive, _ := m.store.CountEnabledProxies(ctx)
|
|
snap.TotalNodes = total
|
|
snap.AliveNodes = alive
|
|
return snap
|
|
}
|
|
|
|
func (m *Collector) RefreshIfStale(ctx context.Context, maxAge time.Duration) cache.DashboardSnapshot {
|
|
snap := m.cache.GetDashboard()
|
|
if !snap.UpdatedAt.IsZero() && time.Since(snap.UpdatedAt) < maxAge {
|
|
return snap
|
|
}
|
|
refreshed, err := m.Refresh(ctx)
|
|
if err != nil {
|
|
return snap
|
|
}
|
|
return refreshed
|
|
}
|
|
|
|
func (m *Collector) RefreshAsync(maxAge time.Duration) {
|
|
snap := m.cache.GetDashboard()
|
|
if !snap.UpdatedAt.IsZero() && time.Since(snap.UpdatedAt) < maxAge {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
if m.refreshing {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
m.refreshing = true
|
|
m.mu.Unlock()
|
|
go func() {
|
|
defer func() {
|
|
m.mu.Lock()
|
|
m.refreshing = false
|
|
m.mu.Unlock()
|
|
}()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
|
defer cancel()
|
|
_, _ = m.Refresh(ctx)
|
|
}()
|
|
}
|
|
|
|
func (m *Collector) Refresh(ctx context.Context) (cache.DashboardSnapshot, error) {
|
|
var snap cache.DashboardSnapshot
|
|
snap.UpdatedAt = time.Now()
|
|
|
|
var (
|
|
traffic TrafficRatesResult
|
|
conns map[string]any
|
|
wg sync.WaitGroup
|
|
)
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
rates, err := mihomoapi.New(m.apiBase, m.secret).TrafficRates(ctx)
|
|
if err == nil {
|
|
traffic = TrafficRatesResult{Up: rates.Up, Down: rates.Down, OK: true}
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
conns, _ = m.getJSON("/connections")
|
|
}()
|
|
wg.Wait()
|
|
|
|
if traffic.OK {
|
|
snap.UploadRate = traffic.Up
|
|
snap.DownloadRate = traffic.Down
|
|
}
|
|
|
|
if conns != nil {
|
|
if arr, ok := conns["connections"].([]any); ok {
|
|
snap.Connections = len(arr)
|
|
m.setConnCache(arr)
|
|
}
|
|
}
|
|
|
|
total, alive, _ := m.store.CountEnabledProxies(ctx)
|
|
snap.TotalNodes = total
|
|
snap.AliveNodes = alive
|
|
|
|
m.cache.SetDashboard(snap)
|
|
_ = m.store.AddTrafficSnapshot(ctx, snap.UploadRate, snap.DownloadRate, int64(snap.Connections))
|
|
return snap, nil
|
|
}
|
|
|
|
func (m *Collector) setConnCache(arr []any) {
|
|
out := make([]map[string]any, 0, len(arr))
|
|
for _, item := range arr {
|
|
if m, ok := item.(map[string]any); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
m.mu.Lock()
|
|
m.connCache = out
|
|
m.connCacheAt = time.Now()
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func (m *Collector) CachedConnections() []map[string]any {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return copyConnSlice(m.connCache)
|
|
}
|
|
|
|
func (m *Collector) GetConnections() ([]map[string]any, error) {
|
|
return m.GetConnectionsCached(0)
|
|
}
|
|
|
|
func (m *Collector) GetConnectionsCached(maxAge time.Duration) ([]map[string]any, error) {
|
|
m.mu.Lock()
|
|
if maxAge > 0 && m.connCache != nil && time.Since(m.connCacheAt) < maxAge {
|
|
out := copyConnSlice(m.connCache)
|
|
m.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
m.RefreshAsync(maxAge)
|
|
if cached := m.CachedConnections(); len(cached) > 0 {
|
|
return cached, nil
|
|
}
|
|
|
|
data, err := m.getJSON("/connections")
|
|
if err != nil {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.connCache != nil {
|
|
return copyConnSlice(m.connCache), nil
|
|
}
|
|
return nil, err
|
|
}
|
|
arr, ok := data["connections"].([]any)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
m.setConnCache(arr)
|
|
return m.CachedConnections(), nil
|
|
}
|
|
|
|
func copyConnSlice(in []map[string]any) []map[string]any {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]map[string]any, len(in))
|
|
copy(out, in)
|
|
return out
|
|
}
|
|
|
|
type TrafficRatesResult struct {
|
|
Up int64
|
|
Down int64
|
|
OK bool
|
|
}
|
|
|
|
func (m *Collector) UpdateAPIBase(apiBase, secret string) {
|
|
m.apiBase = trimRight(apiBase, "/")
|
|
m.secret = secret
|
|
}
|
|
|
|
func (m *Collector) getJSON(path string) (map[string]any, error) {
|
|
req, err := http.NewRequest(http.MethodGet, m.apiBase+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if m.secret != "" {
|
|
req.Header.Set("Authorization", "Bearer "+m.secret)
|
|
}
|
|
resp, err := m.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|