From 35c9bd2cc057a9529a09cda480823338c375ebd6 Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Tue, 20 Oct 2026 20:56:28 +0000 Subject: [PATCH] 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). --- panel-go/golden/golden.json | 22 +++++++++++-- panel-go/handlers.go | 54 +++++++++++++++++++++++++++++- panel-go/summary_test.go | 65 +++++++++++++++++++++++++++++++++++++ panel-go/types.go | 21 +++++++----- public/app.js | 18 +++++++--- public/styles.css | 3 +- 6 files changed, 166 insertions(+), 17 deletions(-) create mode 100644 panel-go/summary_test.go diff --git a/panel-go/golden/golden.json b/panel-go/golden/golden.json index cc07fa5..3e2bec2 100644 --- a/panel-go/golden/golden.json +++ b/panel-go/golden/golden.json @@ -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 }, diff --git a/panel-go/handlers.go b/panel-go/handlers.go index 46b13e5..63ee91c 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -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 { diff --git a/panel-go/summary_test.go b/panel-go/summary_test.go new file mode 100644 index 0000000..dc23901 --- /dev/null +++ b/panel-go/summary_test.go @@ -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) + } +} diff --git a/panel-go/types.go b/panel-go/types.go index 35d7126..4391d9c 100644 --- a/panel-go/types.go +++ b/panel-go/types.go @@ -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 { diff --git a/public/app.js b/public/app.js index 7ee0ec3..5a5a6cd 100644 --- a/public/app.js +++ b/public/app.js @@ -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() { ${remainingPill(item)} ${durationPill(item)}`; }).join(''); - return `
+ const sum = state.customerSummary; + const statCell = (lab, cls, svg, val) => `
${lab}
${svg}${val}
`; + const custStats = sum ? `
+ ${statCell('客户端', 'ic-dim', '', sum.total)} + ${statCell('在线', 'ic-green', '', sum.online)} + ${statCell('耗尽', 'ic-red', '', sum.depleted)} + ${statCell('即将耗尽', 'ic-bolt', '', sum.depletingSoon)} + ${statCell('关闭', 'ic-red', '', sum.disabled)} + ${statCell('启用', 'ic-green', '', sum.enabled)} +
` : ''; + return `${custStats}
${filterNode ? `节点:${esc(filterNode.name)}` : ''} diff --git a/public/styles.css b/public/styles.css index d928602..817a94c 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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}}