feat(panel): customers stats strip with 3x-ui depletion semantics (round-2 #1)
GET /api/customers gains a summary object (total/online/depleted/ depletingSoon/disabled/enabled) computed server-side; the customers page renders it as a six-cell stats strip above the toolbar. Predicates aligned verbatim with 3x-ui client_paging.go: - depleted = (limit>0 && used>=limit) || (expiry set && expiry<=now), counted regardless of status; - depletingSoon = active && !depleted && (expiry within expireDiff days || remaining traffic < trafficDiff GB), thresholds from new settings expireDiff/trafficDiff with 3x-ui's factory default 0 (nobody counts until configured); - enabled = status active; disabled = manual suspensions only. Online reuses the existing 3-minute presence projection. Golden diff confined to the additive summary keys; summary_test.go covers every bucket with thresholds off (soon=0) and on (soon=2).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"capturedAt": "2026-10-20T20:52:29.987Z",
|
||||
"capturedAt": "2026-10-20T20:56:25.514Z",
|
||||
"note": "Go panel golden baseline; see README.md in this directory."
|
||||
},
|
||||
"sections": {
|
||||
@@ -105,7 +105,15 @@
|
||||
"usedDownlinkBytes": 0,
|
||||
"usedUplinkBytes": 0
|
||||
}
|
||||
]
|
||||
],
|
||||
"summary": {
|
||||
"depleted": 0,
|
||||
"depletingSoon": 0,
|
||||
"disabled": 0,
|
||||
"enabled": 2,
|
||||
"online": 0,
|
||||
"total": 2
|
||||
}
|
||||
},
|
||||
"status": 200
|
||||
},
|
||||
@@ -1298,7 +1306,15 @@
|
||||
"usedDownlinkBytes": 0,
|
||||
"usedUplinkBytes": 0
|
||||
}
|
||||
]
|
||||
],
|
||||
"summary": {
|
||||
"depleted": 0,
|
||||
"depletingSoon": 0,
|
||||
"disabled": 0,
|
||||
"enabled": 3,
|
||||
"online": 1,
|
||||
"total": 3
|
||||
}
|
||||
},
|
||||
"status": 200
|
||||
},
|
||||
|
||||
+53
-1
@@ -551,6 +551,58 @@ func PublicCustomer(item *Customer, data *Data) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// CustomerSummary computes the customers-page stats strip, mirroring
|
||||
// 3x-ui's client summary semantics (client_paging.go): total / online /
|
||||
// depleted / depletingSoon / disabled / enabled. "depleted" is 3x-ui's
|
||||
// depletedExpr — traffic exhausted (limit > 0 and used >= limit) OR expiry
|
||||
// reached — counted regardless of status. "depletingSoon" is its
|
||||
// expiringExpr — status active, not depleted, and either the expiry is
|
||||
// less than settings.expireDiff days away or the remaining traffic is
|
||||
// less than settings.trafficDiff GB. Both thresholds default to 0 (the
|
||||
// 3x-ui factory default), under which nobody counts as depleting soon.
|
||||
// "disabled" counts manual suspensions only; auto-blocks (expired /
|
||||
// traffic_limit / ip_limit) surface through depleted instead.
|
||||
func CustomerSummary(data *Data, nowMs int64) map[string]any {
|
||||
expireDiffMs := data.Settings.ExpireDiff * 86400000
|
||||
trafficDiffBytes := data.Settings.TrafficDiff * 1073741824
|
||||
total, online, depleted, depletingSoon, disabled, enabled := len(data.Customers), 0, 0, 0, 0, 0
|
||||
for _, c := range data.Customers {
|
||||
if CustomerOnline(c, nowMs) {
|
||||
online++
|
||||
}
|
||||
if c.Status == "active" {
|
||||
enabled++
|
||||
}
|
||||
if c.Status == "suspended" && strVal(c.SuspendReason) == "manual" {
|
||||
disabled++
|
||||
}
|
||||
var expiryMs int64 = -1
|
||||
if c.ExpiresAt != nil {
|
||||
if ms, ok := ParseISOMs(*c.ExpiresAt); ok {
|
||||
expiryMs = ms
|
||||
}
|
||||
}
|
||||
isDepleted := (c.TrafficLimitBytes > 0 && float64(c.UsedBytes) >= c.TrafficLimitBytes) ||
|
||||
(expiryMs >= 0 && expiryMs <= nowMs)
|
||||
if isDepleted {
|
||||
depleted++
|
||||
continue
|
||||
}
|
||||
if c.Status != "active" {
|
||||
continue
|
||||
}
|
||||
nearExpiry := expiryMs >= 0 && float64(expiryMs-nowMs) < expireDiffMs
|
||||
nearLimit := c.TrafficLimitBytes > 0 && c.TrafficLimitBytes-float64(c.UsedBytes) < trafficDiffBytes
|
||||
if nearExpiry || nearLimit {
|
||||
depletingSoon++
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"total": total, "online": online, "depleted": depleted,
|
||||
"depletingSoon": depletingSoon, "disabled": disabled, "enabled": enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// credentialItems mirrors credentialItems in server.js.
|
||||
func credentialItems(d *Data, customer *Customer) []map[string]any {
|
||||
items := []map[string]any{}
|
||||
@@ -1751,7 +1803,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
out["speedDownBps"] = down
|
||||
customers = append(customers, out)
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"customers": customers}, nil)
|
||||
SendJSON(w, 200, map[string]any{"customers": customers, "summary": CustomerSummary(data, nowMs)}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/access", pathname); ok && r.Method == http.MethodGet {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
// Tests for CustomerSummary (round-2 item 1): the six counts on the
|
||||
// customers page, with depleted/depletingSoon predicates aligned to
|
||||
// 3x-ui's depletedExpr/expiringExpr (client_paging.go) — including its
|
||||
// panel-configurable expireDiff (days) / trafficDiff (GB) thresholds,
|
||||
// which default to 0 and then count nobody as depleting soon.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func summaryFixture(now time.Time) *Data {
|
||||
iso := func(t time.Time) *string { s := t.UTC().Format(time.RFC3339); return &s }
|
||||
manual := "manual"
|
||||
expiredReason := "expired"
|
||||
gb := float64(1073741824)
|
||||
return &Data{Customers: []*Customer{
|
||||
// active + online + plain
|
||||
{ID: "cus_a", Name: "A", Status: "active", LastOnlineAt: iso(now)},
|
||||
// active, traffic exhausted
|
||||
{ID: "cus_b", Name: "B", Status: "active", TrafficLimitBytes: 1000, UsedBytes: 1000},
|
||||
// active, 0.5GB remaining of 2GB
|
||||
{ID: "cus_c", Name: "C", Status: "active", TrafficLimitBytes: 2 * gb, UsedBytes: int64(1.5 * gb)},
|
||||
// active, expires in 2 days
|
||||
{ID: "cus_d", Name: "D", Status: "active", ExpiresAt: iso(now.Add(48 * time.Hour))},
|
||||
// active, expires in 10 days
|
||||
{ID: "cus_e", Name: "E", Status: "active", ExpiresAt: iso(now.Add(240 * time.Hour))},
|
||||
// manually disabled
|
||||
{ID: "cus_f", Name: "F", Status: "suspended", SuspendReason: &manual, SuspendReasonPresent: true},
|
||||
// expired (auto-suspended): depleted, not "disabled"
|
||||
{ID: "cus_g", Name: "G", Status: "suspended", SuspendReason: &expiredReason, SuspendReasonPresent: true, ExpiresAt: iso(now.Add(-time.Hour))},
|
||||
// active, unlimited with heavy usage: never depleted
|
||||
{ID: "cus_h", Name: "H", Status: "active", UsedBytes: int64(9 * gb)},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestCustomerSummaryDefaultThresholds(t *testing.T) {
|
||||
now := time.Now()
|
||||
d := summaryFixture(now)
|
||||
s := CustomerSummary(d, now.UnixMilli())
|
||||
want := map[string]int{"total": 8, "online": 1, "depleted": 2, "depletingSoon": 0, "disabled": 1, "enabled": 6}
|
||||
for k, v := range want {
|
||||
if s[k] != v {
|
||||
t.Fatalf("summary[%s] = %v, want %d (full: %v)", k, s[k], v, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerSummaryWithThresholds(t *testing.T) {
|
||||
now := time.Now()
|
||||
d := summaryFixture(now)
|
||||
d.Settings.ExpireDiff = 3 // days
|
||||
d.Settings.TrafficDiff = 1 // GB
|
||||
s := CustomerSummary(d, now.UnixMilli())
|
||||
// C (0.5GB remaining < 1GB) and D (expiry in 2d < 3d) become depleting soon;
|
||||
// E (10d) does not. Nothing else moves.
|
||||
if s["depletingSoon"] != 2 {
|
||||
t.Fatalf("depletingSoon = %v, want 2 (full: %v)", s["depletingSoon"], s)
|
||||
}
|
||||
if s["depleted"] != 2 || s["enabled"] != 6 || s["disabled"] != 1 {
|
||||
t.Fatalf("other counts moved: %v", s)
|
||||
}
|
||||
}
|
||||
+13
-8
@@ -268,14 +268,19 @@ func (d *Data) Clone() *Data {
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
BrandName string `json:"brandName"`
|
||||
SessionHours float64 `json:"sessionHours"`
|
||||
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
|
||||
ActivityRetentionDays float64 `json:"activityRetentionDays"`
|
||||
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
|
||||
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
|
||||
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
|
||||
Extra extraFields `json:"-"`
|
||||
BrandName string `json:"brandName"`
|
||||
SessionHours float64 `json:"sessionHours"`
|
||||
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
|
||||
ActivityRetentionDays float64 `json:"activityRetentionDays"`
|
||||
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
|
||||
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
|
||||
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
|
||||
// Depleting-soon thresholds for the customers summary, aligned with
|
||||
// 3x-ui's expireDiff/trafficDiff settings (same names, units and
|
||||
// factory default of 0 = nobody counts as depleting soon).
|
||||
ExpireDiff float64 `json:"expireDiff,omitempty"`
|
||||
TrafficDiff float64 `json:"trafficDiff,omitempty"`
|
||||
Extra extraFields `json:"-"`
|
||||
}
|
||||
|
||||
func (s *Settings) UnmarshalJSON(raw []byte) error {
|
||||
|
||||
Reference in New Issue
Block a user