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:
aaaamoooo
2026-10-20 20:56:28 +00:00
parent 161a5d78d1
commit 6e82d0d06a
6 changed files with 166 additions and 17 deletions
+19 -3
View File
@@ -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
View File
@@ -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 {
+65
View File
@@ -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)
}
}
+5
View File
@@ -275,6 +275,11 @@ type Settings struct {
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:"-"`
}
+14 -4
View File
@@ -252,17 +252,17 @@ async function load(page = state.page) {
if (page === 'overview') {
const [overview, system, servers, customers, routes] = await Promise.all([api('/api/overview'), api('/api/system'), api('/api/servers'), api('/api/customers'), api('/api/nodes')]);
state.overview = overview; state.system = system; state.version = overview.version || state.version;
state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
$('#version-label').textContent = `v${state.version}`;
} else if (page === 'servers') {
const [servers, routes] = await Promise.all([api('/api/servers'), api('/api/nodes')]);
state.servers = servers.servers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
} else if (page === 'customers') {
const [customers, routes] = await Promise.all([api('/api/customers'), api('/api/nodes')]);
state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
} else if (page === 'chains') {
const [servers, customers, routes, protocols] = await Promise.all([api('/api/servers'), api('/api/customers'), api('/api/nodes'), api('/api/protocols')]);
state.servers = servers.servers; state.customers = customers.customers; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.servers = servers.servers; state.customers = customers.customers; state.customerSummary = customers.summary || null; state.nodes = routes.nodes; state.nodeClients = routes.nodeClients;
state.protocols = protocols.profiles; state.realityPresets = protocols.realityPresets || [];
} else if (page === 'commands') { /* 静态页 */ }
else if (page === 'operations') {
@@ -552,7 +552,17 @@ function renderCustomers() {
<td>${remainingPill(item)}</td>
<td>${durationPill(item)}</td></tr>`;
}).join('');
return `<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
const sum = state.customerSummary;
const statCell = (lab, cls, svg, val) => `<div class="stat"><div class="lab">${lab}</div><div class="val"><svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${svg}</svg>${val}</div></div>`;
const custStats = sum ? `<section class="stats six">
${statCell('客户端', 'ic-dim', '<circle cx="9" cy="8" r="3.5"/><path d="M3.5 19c.6-3 2.8-4.5 5.5-4.5s4.9 1.5 5.5 4.5"/><path d="M16 5.5a3.5 3.5 0 010 6.5M17.5 14.7c1.7.6 2.7 1.9 3 4.3"/>', sum.total)}
${statCell('在线', 'ic-green', '<path d="M3 12h4l2.5-6 4 12L16 12h5"/>', sum.online)}
${statCell('耗尽', 'ic-red', '<rect x="3" y="8" width="15" height="8" rx="2"/><path d="M21 11v2"/><path d="M6.5 11v2"/>', sum.depleted)}
${statCell('即将耗尽', 'ic-bolt', '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', sum.depletingSoon)}
${statCell('关闭', 'ic-red', '<path d="M12 3v8"/><path d="M6.3 6.5a8 8 0 1 0 11.4 0"/>', sum.disabled)}
${statCell('启用', 'ic-green', '<circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 4.5-5.5"/>', sum.enabled)}
</section>` : '';
return `${custStats}<div class="page-tools"><button class="btn primary" data-action="add-customer">+ 添加客户</button>
<div class="toolbar"><input class="search" data-search placeholder="搜索客户、备注、分组或标签" value="${esc(state.search)}">
<select class="search" data-group-filter><option value="">全部分组</option>${groups.map((g) => `<option value="${esc(g)}"${selected(g, state.groupFilter)}>${esc(g)}</option>`).join('')}</select>
${filterNode ? `<span class="filter-chip">节点:${esc(filterNode.name)}<button type="button" data-action="clear-node-filter" data-tip="清除">×</button></span>` : ''}
+2 -1
View File
@@ -210,6 +210,7 @@ tbody tr.drag-over td{border-top:2px solid var(--primary)}
/* ---------- 服务器页 ---------- */
.stats{display:grid;grid-template-columns:repeat(3,1fr);background:var(--card);border:1px solid var(--border-soft);border-radius:var(--radius);margin-bottom:14px}
.stats.six{grid-template-columns:repeat(6,1fr)}
.stats .stat{padding:14px 22px;border:none;background:transparent}
.stats .stat .lab{color:var(--text-2);font-size:12.5px;margin-bottom:3px}
.stats .stat .val{display:flex;align-items:center;gap:8px;font-size:20px;font-weight:600;font-variant-numeric:tabular-nums}
@@ -401,5 +402,5 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri
.toast{background:var(--pop-bg);border:1px solid var(--border);border-left:3px solid var(--green);color:var(--text);border-radius:8px;box-shadow:var(--pop-shadow);padding:9px 14px;font-size:13px;max-width:360px}
.toast.error{border-left-color:var(--red)}
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}}
@media (max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}.mid-grid{grid-template-columns:1fr}.info-bar{flex-direction:column;gap:14px}.info-bar .seg{padding:0}.info-bar .seg+.seg{border-left:none}.stats{grid-template-columns:repeat(2,1fr)}.stats.six{grid-template-columns:repeat(3,1fr)}}
@media (max-width:760px){.sider{display:none}.form-grid{grid-template-columns:1fr}.top-search{display:none}.content{padding:8px}}