From bbc90fbf746485014c7216cb1bf01581430b4e83 Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:39:39 +0000 Subject: [PATCH] fix(bandwidth): name the right month in the quota label; make the reset-day change explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with `panel.bandwidth_reset_day`, both around its interaction with the quota period: - The user panel hardcoded "至次月 N 号", which is wrong whenever the reset day is still ahead of today: the period in force started on that day of the PREVIOUS month, so it ends on that day of THIS one (reset day 22 on the 16th ends on the 22nd of this month). mgr.BandwidthNextReset derives the real moment, the page flags whether it lands in this calendar month, and the label follows. A test also pins it against BandwidthPeriod, so the label and the rollover can never disagree about the boundary. - Changing the day DISCARDS every user's accumulated bandwidth totals. That is not an explicit reset: the counters are keyed by a period derived from the day, so moving the day moves the key and the next sampler pass sees a new period. It stays that way (it is the period model), but it is no longer silent: the key is classified ApplyDestructive, so `vps config set` shows a confirmation naming the loss, repeats it on the --no-apply path (where the loss lands at the next panel restart instead), and prints it after applying. docs/configuration.md explains the mechanism and points at the admin panel's per-user "重置流量统计" button for a deliberate reset. TestApplyBandwidthPeriodChangeDiscardsTotals pins the discard so the warning cannot quietly become wrong, and TestBandwidthResetDayIsDestructive keeps the classification from regressing to a plain restart. Co-authored-by: CommandCodeBot --- docs/configuration.md | 28 +++++++- src/internal/cfg/registry.go | 17 +++-- src/internal/cfg/registry_test.go | 35 ++++++++++ src/internal/db/bandwidth_test.go | 48 +++++++++++++ src/internal/mgr/bandwidth.go | 33 +++++++++ src/internal/mgr/bandwidth_test.go | 79 ++++++++++++++++++++++ src/internal/panel/panel.go | 61 ++++++++++------- src/internal/panel/panel_test.go | 67 +++++++++++++++--- src/internal/panel/templates/overview.html | 2 +- src/main.go | 32 +++++++-- 10 files changed, 356 insertions(+), 46 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ae6561b..3496235 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,7 +35,7 @@ same table; `vps config list` shows the live values with this annotation. | `panel.url_path` | **fixed at install** | — | secret prefix of the user panel; settable only while empty (re-enable) | | `panel.admin_url_path` | operator | restart panel | secret prefix of the admin panel; an **empty value disables the admin panel** (shown as `disabled`) | | `panel.show_footer` | operator | restart panel | user panel footer with project link and installed/build version; `false` hides the entire footer | -| `panel.bandwidth_reset_day` | operator | restart panel | day of month the monthly bandwidth quota resets, `1`-`28` (28 keeps February safe); default `1` | +| `panel.bandwidth_reset_day` | operator | restart panel — **discards stored state** | day of month the monthly bandwidth quota resets, `1`-`28` (28 keeps February safe); default `1`. **Changing it wipes every user's accumulated traffic totals** — see below | | `panel.admin_pass_hash` | managed elsewhere | — | bcrypt hash of the admin password; stored in the **DB**, set via `vps admin-passwd` / web UI | | `net.subnet` | **fixed at install** | — | container subnet `10..0.0/24`; changing breaks existing containers | | `net.gateway` | **fixed at install** | — | bridge gateway (derived from subnet) | @@ -100,7 +100,9 @@ panel: public_ip: AUTO # NIC IPv4 used by the firewall / routing; on NAT-ing clouds (AWS/Alibaba) this is a private address display_ip: AUTO # address shown to users (panel URL, SSH hints); any string without spaces (IP or domain); auto-fetched from ipv4.ip.sb when public_ip is private; empty = fall back to public_ip session_days: 3 # login session lifetime (days) - bandwidth_reset_day: 1 # day of month the monthly bandwidth quota resets (1-28) + bandwidth_reset_day: 1 # day of month the monthly bandwidth quota resets (1-28). + # CHANGING IT WIPES every user's accumulated traffic totals. + # `vps config set` asks for confirmation. url_path: AUTO # random secret path, the only panel entrance; do not change after first install admin_url_path: AUTO # random secret path of the admin panel; do not change after first install admin_pass_hash: AUTO # bcrypt hash of the admin password — stored in the DB, not in this file @@ -265,6 +267,28 @@ is a deliberate migration — update `HAPROXY_BRANCH` / `HAPROXY_REPO_SLUG` / | `/etc/haproxy/generation` | panel — counter of the last published generation | | `/etc/systemd/system/haproxy.service` | install (overrides the distribution's unit) | +## Monthly bandwidth reset day (`panel.bandwidth_reset_day`) + +The quota period runs from the configured day of one month to that day of the +next (default `1` = calendar months). The user panel shows which reset applies +to the period in force, including which month it falls in — with the reset day +set to the 22nd and today the 16th, the period ends on the **22nd of this +month**, because it started on the 22nd of the previous one. + +> **Changing this setting discards every user's accumulated bandwidth totals.** +> The accumulated counters are keyed by an accounting period derived from this +> day, so moving the day moves the period key, and the next sampler pass sees +> "a new period" and starts from zero. Any over-quota throttle is lifted as a +> side effect, and past totals are not recoverable — setting the day back does +> not bring them back. +> +> This is why the key is classified as destructive: `vps config set +> panel.bandwidth_reset_day ` asks for an explicit confirmation describing +> the loss (and repeats it when the change is saved with `--no-apply`, since +> then it lands at the next panel restart instead). To reset ONE user's traffic +> on purpose, use the admin panel's "重置流量统计" button instead — that is +> the supported, per-user operation. + ## Port 25 (SMTP) is always blocked The host nftables ruleset drops **port 25 for all forwarded traffic, both diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index 20cb5af..075fbcc 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -53,11 +53,12 @@ func (k FieldKind) String() string { type Apply int const ( - ApplyRestart Apply = iota // panel is restarted automatically by `vps config set` - ApplyInstall // re-run `vps install` (or `vps config set --apply`) - ApplyImmediate // applied by vps config set right now - ApplyNextAdd // used on the next vps add / reinstall - ApplyNone // not settable + ApplyRestart Apply = iota // panel is restarted automatically by `vps config set` + ApplyInstall // re-run `vps install` (or `vps config set --apply`) + ApplyImmediate // applied by vps config set right now + ApplyNextAdd // used on the next vps add / reinstall + ApplyDestructive // applied by a panel restart, and applying it DISCARDS stored state + ApplyNone // not settable ) func (a Apply) String() string { @@ -70,6 +71,8 @@ func (a Apply) String() string { return "applied immediately" case ApplyNextAdd: return "next add / reinstall" + case ApplyDestructive: + return "restart panel (auto), discards stored state" case ApplyNone: return "-" } @@ -227,8 +230,8 @@ var Fields = []Field{ c.Panel.ShowFooter = b return nil }}, - {"panel.bandwidth_reset_day", KindOperator, ApplyRestart, - "day of month the monthly bandwidth quota resets (1-28; 28 keeps February safe)", + {"panel.bandwidth_reset_day", KindOperator, ApplyDestructive, + "day of month the monthly bandwidth quota resets (1-28; 28 keeps February safe). WARNING: changing it discards EVERY user's accumulated bandwidth totals — the accounting period key moves, so the next sampler pass starts a fresh period", "1", getStr(func(c *Config) string { return strconv.Itoa(c.Panel.BandwidthResetDay) }), func(c *Config, v string) error { diff --git a/src/internal/cfg/registry_test.go b/src/internal/cfg/registry_test.go index 334958f..51f9d2d 100644 --- a/src/internal/cfg/registry_test.go +++ b/src/internal/cfg/registry_test.go @@ -370,3 +370,38 @@ func TestSwapRatioValidator(t *testing.T) { } } } + +// TestBandwidthResetDayIsDestructive pins the classification that makes +// `vps config set panel.bandwidth_reset_day` ask for confirmation: changing +// the day moves the accounting period key, which discards every user's +// accumulated bandwidth totals (see TestApplyBandwidthPeriodChangeDiscardsTotals +// in internal/db). If this ever drops back to a plain ApplyRestart the change +// would happen silently again. +func TestBandwidthResetDayIsDestructive(t *testing.T) { + f := FieldFor("panel.bandwidth_reset_day") + if f == nil { + t.Fatal("panel.bandwidth_reset_day is not registered") + } + if f.Apply != ApplyDestructive { + t.Errorf("Apply = %v, want ApplyDestructive", f.Apply) + } + // The warning has to name the consequence, since that is all the operator + // sees in `vps config help`. + for _, want := range []string{"discards", "bandwidth"} { + if !strings.Contains(f.Desc, want) { + t.Errorf("description does not mention %q: %s", want, f.Desc) + } + } + // And the value range is unchanged. + c := Default() + for _, bad := range []string{"0", "29", "abc"} { + if err := f.Assign(c, bad); err == nil { + t.Errorf("invalid %q accepted", bad) + } + } + for _, ok := range []string{"1", "22", "28"} { + if err := f.Assign(c, ok); err != nil { + t.Errorf("valid %q rejected: %v", ok, err) + } + } +} diff --git a/src/internal/db/bandwidth_test.go b/src/internal/db/bandwidth_test.go index 0ae6d3d..c5f853f 100644 --- a/src/internal/db/bandwidth_test.go +++ b/src/internal/db/bandwidth_test.go @@ -177,6 +177,54 @@ func TestApplyBandwidthResetNewPid(t *testing.T) { wantBandwidth(t, tr, "2026-08", 100, 300, 300, 100, 555) } +// TestApplyBandwidthPeriodChangeDiscardsTotals pins the behaviour that +// `panel.bandwidth_reset_day` has to warn about: the monthly totals are reset +// whenever the PERIOD KEY changes, and moving the configured reset day moves +// the key. Changing it from 1 to 22 on the 16th therefore looks like a +// rollover to the sampler, which zeroes every user's accumulated traffic on +// its next pass — not because anything resets it explicitly, but because the +// period the totals belonged to no longer exists. +// +// Fixing the label (mgr.BandwidthNextReset) does NOT change this: it is why +// the config key is marked destructive and confirmed at `vps config set`. +func TestApplyBandwidthPeriodChangeDiscardsTotals(t *testing.T) { + d := openTestDB(t) + u := mkUser(t, d, "erin", 5) + + // Accumulate under the day-1 period key, as a default install does. + if err := d.ApplyBandwidth(u.ID, "2026-09", 1000, 500, 777); err != nil { + t.Fatal(err) + } + if err := d.ApplyBandwidth(u.ID, "2026-09", 4000, 2500, 777); err != nil { + t.Fatal(err) + } + tr, err := d.GetBandwidth(u.ID) + if err != nil { + t.Fatal(err) + } + wantBandwidth(t, tr, "2026-09", 2000, 3000, 4000, 2500, 777) + + // The operator moves the reset day to the 22nd on the 16th, so the key + // becomes the previous month's. + if err := d.ApplyBandwidth(u.ID, "2026-08", 4000, 2500, 777); err != nil { + t.Fatal(err) + } + tr, err = d.GetBandwidth(u.ID) + if err != nil { + t.Fatal(err) + } + // Totals discarded; the counter baselines are preserved (unchanged + // counters add nothing), so counting restarts from here. + wantBandwidth(t, tr, "2026-08", 0, 0, 4000, 2500, 777) + + // Traffic after the change is counted again normally. + if err := d.ApplyBandwidth(u.ID, "2026-08", 4500, 3000, 777); err != nil { + t.Fatal(err) + } + tr, _ = d.GetBandwidth(u.ID) + wantBandwidth(t, tr, "2026-08", 500, 500, 4500, 3000, 777) +} + func TestBandwidthCascadeDelete(t *testing.T) { d := openTestDB(t) u := mkUser(t, d, "bob", 2) diff --git a/src/internal/mgr/bandwidth.go b/src/internal/mgr/bandwidth.go index 0206292..127ebba 100644 --- a/src/internal/mgr/bandwidth.go +++ b/src/internal/mgr/bandwidth.go @@ -59,6 +59,39 @@ func BandwidthPeriod(t time.Time, resetDay int) string { return firstOfMonth.AddDate(0, -1, 0).Format("2006-01") } +// BandwidthNextReset returns the moment the quota period in force ends, i.e. +// when the current period's counters roll over. +// +// It is the configured day of the month AFTER the current period's start +// month — not "the configured day next month". The distinction matters right +// after the reset day has passed: with resetDay=1 and today mid-month, the next +// reset is the 1st of the FOLLOWING month, while with resetDay=22 and today the +// 16th it is the 22nd of THIS month (the period still started last month). +// +// The caller renders it; ThisNextMonth reports whether that lands in the +// current calendar month or the next one. resetDay is clamped exactly like +// BandwidthPeriod (1..28) so the two can never disagree about the boundary. +func BandwidthNextReset(t time.Time, resetDay int) time.Time { + if resetDay < 1 || resetDay > 28 { + resetDay = 1 + } + t = t.UTC() + // Same period arithmetic as BandwidthPeriod: on/after resetDay we are in + // this month's period, before it we are still in last month's. + year, month := t.Year(), t.Month() + if t.Day() < resetDay { + month-- + if month < time.January { + month = time.December + year-- + } + } + // The period ends on the reset day of the following month. + firstOfPeriod := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + end := firstOfPeriod.AddDate(0, 1, 0) + return time.Date(end.Year(), end.Month(), resetDay, 0, 0, 0, 0, time.UTC) +} + // ParseBandwidthGB parses a bandwidth quota in GiB: empty or "0" = unlimited, // otherwise a non-negative integer. func ParseBandwidthGB(s string) (int, error) { diff --git a/src/internal/mgr/bandwidth_test.go b/src/internal/mgr/bandwidth_test.go index 0253d72..01348ac 100644 --- a/src/internal/mgr/bandwidth_test.go +++ b/src/internal/mgr/bandwidth_test.go @@ -83,6 +83,85 @@ func TestBandwidthPeriod(t *testing.T) { } } +// TestBandwidthNextReset pins the date the quota label shows. The next reset +// is the configured day of the month AFTER the current PERIOD started — not +// "the configured day next month" — which is exactly the distinction that made +// the label wrong before: with resetDay=22 on the 16th the period ends on the +// 22nd of THIS month. +func TestBandwidthNextReset(t *testing.T) { + day := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } + cases := []struct { + name string + t time.Time + reset int + want time.Time + thisMon bool // does the reset land in the same calendar month as t? + }{ + // The reported case: today is the 16th, the reset day is the 22nd, so + // the period in force started on the 22nd of LAST month and ends on the + // 22nd of THIS one. + {"16th with reset 22 -> this month", day(2026, 9, 16), 22, day(2026, 9, 22), true}, + // Once that day has passed, the next one is next month's. + {"23rd with reset 22 -> next month", day(2026, 9, 23), 22, day(2026, 10, 22), false}, + {"on the reset day -> next month", day(2026, 9, 22), 22, day(2026, 10, 22), false}, + // Reset day 1: mid-month the next reset is always the 1st of next month. + {"16th with reset 1 -> next month", day(2026, 9, 16), 1, day(2026, 10, 1), false}, + // ... except on the last day of a month, where it is the very next day + // (still a different calendar month, so the label says "next month"). + {"last day with reset 1 -> September", day(2026, 8, 31), 1, day(2026, 9, 1), false}, + {"just after new year with reset 1", day(2026, 1, 1), 1, day(2026, 2, 1), false}, + // Year rollover, both directions. + {"before reset in January", day(2026, 1, 10), 15, day(2026, 1, 15), true}, + {"December past the reset day", day(2026, 12, 20), 15, day(2027, 1, 15), false}, + // February is why the day is capped at 28. + {"Feb with reset 28", day(2026, 2, 27), 28, day(2026, 2, 28), true}, + {"Feb with reset 28 past", day(2026, 2, 28), 28, day(2026, 3, 28), false}, + // Out of range clamps to 1, exactly like BandwidthPeriod. + {"out of range clamps to 1", day(2026, 9, 16), 0, day(2026, 10, 1), false}, + {"out of range clamps to 1 (high)", day(2026, 9, 16), 40, day(2026, 10, 1), false}, + } + for _, c := range cases { + got := BandwidthNextReset(c.t, c.reset) + if !got.Equal(c.want) { + t.Errorf("%s: BandwidthNextReset(%s, %d) = %s, want %s", + c.name, c.t.Format("2006-01-02"), c.reset, got.Format("2006-01-02"), c.want.Format("2006-01-02")) + } + if wantThisMon := got.Month() == c.t.Month() && got.Year() == c.t.Year(); wantThisMon != c.thisMon { + t.Errorf("%s: same-month flag = %v, want %v", c.name, wantThisMon, c.thisMon) + } + } +} + +// TestBandwidthNextResetAgreesWithPeriod keeps the two helpers from drifting: +// the period key flips exactly AT the returned reset moment, so a label built +// from one and a rollover driven by the other can never disagree about when +// the counters reset. +func TestBandwidthNextResetAgreesWithPeriod(t *testing.T) { + for _, reset := range []int{1, 5, 15, 22, 28} { + for d := 1; d <= 28; d++ { + now := time.Date(2026, 9, d, 12, 0, 0, 0, time.UTC) + next := BandwidthNextReset(now, reset) + if !next.After(now) { + t.Errorf("reset=%d day=%d: next reset %s is not in the future of %s", + reset, d, next.Format("2006-01-02"), now.Format("2006-01-02")) + } + // Just before the boundary the OLD period is still in force; at the + // boundary itself the key has already advanced. + before := BandwidthPeriod(next.Add(-time.Second), reset) + at := BandwidthPeriod(next, reset) + if before == at { + t.Errorf("reset=%d day=%d: period did not advance at %s (%s -> %s)", + reset, d, next.Format("2006-01-02"), before, at) + } + // And the period in force right now must be the one this reset ends. + if got := BandwidthPeriod(now, reset); got != before { + t.Errorf("reset=%d day=%d: period now = %s, but the reset at %s ends %s", + reset, d, got, next.Format("2006-01-02"), before) + } + } + } +} + func TestParseBandwidthGB(t *testing.T) { for s, want := range map[string]int{"": 0, "0": 0, "100": 100, " 50 ": 50} { got, err := ParseBandwidthGB(s) diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index afe545d..bbab2b3 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -167,30 +167,35 @@ type pageData struct { BandwidthUsedGB string // used this month (GB, 1 decimal) — only set when limited BandwidthPct int // used/quota * 100, clamped to 100 BandwidthResetDay int // day of month the bandwidth period resets (1-28) - Throttled bool // over quota: NIC limited to 1Mbps - ExpiresAt string // quota validity deadline (RFC3339 UTC), "" = permanent - Expired bool // deadline passed: account locked to read-only - ExpiresSoon bool // within 72h of the deadline - CPULimited bool // under the admin's dynamic CPU limit - CPULimitUntil int64 // unix seconds the dynamic CPU limit expires - Domains []domainRow - QuotaCPU string - QuotaMem string - QuotaDisk string - CPUUse string // 5-minute CPU average ("12%" or "-") - MemUse string // latest sampled memory usage ("345 MiB" or "-") - DiskUsed string // latest sampled disk usage ("184 MiB" or "-") - Msg string - Err string - PublicIP string - Prefix string - Lang string - UpGB string - DownGB string - IPv6 string // primary global address (the one to connect to) - IPv6Block string // the /112 block the container owns (informational) - Snapshots []snapshotRow - SnapshotLimit int // configured per-container snapshot cap (for display) + // BandwidthResetThisMonth reports whether the NEXT reset falls in the + // calendar month we are in. The label has to say which month: right after + // the reset day has passed it is still this month (reset day 22 on the 16th + // ends the period on the 22nd of THIS month). + BandwidthResetThisMonth bool + Throttled bool // over quota: NIC limited to 1Mbps + ExpiresAt string // quota validity deadline (RFC3339 UTC), "" = permanent + Expired bool // deadline passed: account locked to read-only + ExpiresSoon bool // within 72h of the deadline + CPULimited bool // under the admin's dynamic CPU limit + CPULimitUntil int64 // unix seconds the dynamic CPU limit expires + Domains []domainRow + QuotaCPU string + QuotaMem string + QuotaDisk string + CPUUse string // 5-minute CPU average ("12%" or "-") + MemUse string // latest sampled memory usage ("345 MiB" or "-") + DiskUsed string // latest sampled disk usage ("184 MiB" or "-") + Msg string + Err string + PublicIP string + Prefix string + Lang string + UpGB string + DownGB string + IPv6 string // primary global address (the one to connect to) + IPv6Block string // the /112 block the container owns (informational) + Snapshots []snapshotRow + SnapshotLimit int // configured per-container snapshot cap (for display) // Snapshot share: the code others can use to install from a checkpoint, and // the checkpoint it points at ("" when the user has no active share). ShareCode string @@ -402,6 +407,14 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData { Msg: msg, Err: errMsg, } + // The quota label has to name the month the next reset lands in: it is + // derived from the configured day, not hardcoded to "next month" — with + // resetDay=22 on the 16th the period ends on the 22nd of THIS month. + { + now := time.Now().UTC() + next := mgr.BandwidthNextReset(now, s.cfg.Panel.BandwidthResetDay) + d.BandwidthResetThisMonth = next.Month() == now.Month() && next.Year() == now.Year() + } // Quota validity: the client renders the live countdown from ExpiresAt; the // server flags the expired/locked state (authoritative for enforcement). { diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index 7f4a41b..ac4344a 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -1973,16 +1973,67 @@ func TestOverviewImpersonationBanner(t *testing.T) { } } -// TestOverviewBandwidthResetDayLabel verifies the quota label shows the -// configured monthly reset day ("至次月 N 号"). +// TestOverviewBandwidthResetDayLabel verifies the quota label names the right +// MONTH for the next reset. The label used to hardcode "次月", which is wrong +// whenever the reset day is still ahead of today: the period in force started +// on that day of the previous month, so it ends on that day of THIS one. func TestOverviewBandwidthResetDayLabel(t *testing.T) { srv, _ := newTestServer(t) - html := srv.renderToString(t, "overview.html", pageData{ - User: &db.User{Name: "alice"}, Prefix: "/" + testSecret, Lang: langZh, - BandwidthQuotaGB: 100, BandwidthUsedGB: "1.0", BandwidthResetDay: 5, - }) - if !strings.Contains(html, "流量配额(至次月5号)") { - t.Error("overview missing the bandwidth reset-day label") + cases := []struct { + name string + day int + thisMonth bool + wantZh string + wantEn string + }{ + {"reset still ahead -> this month", 22, true, "流量配额(至本月22号)", "Bandwidth quota (until day 22 this month)"}, + {"reset day passed -> next month", 5, false, "流量配额(至次月5号)", "Bandwidth quota (until day 5 next month)"}, + } + for _, c := range cases { + zh := srv.renderToString(t, "overview.html", pageData{ + User: &db.User{Name: "alice"}, Prefix: "/" + testSecret, Lang: langZh, + BandwidthQuotaGB: 100, BandwidthUsedGB: "1.0", + BandwidthResetDay: c.day, BandwidthResetThisMonth: c.thisMonth, + }) + if !strings.Contains(zh, c.wantZh) { + t.Errorf("%s: zh label missing %q", c.name, c.wantZh) + } + en := srv.renderToString(t, "overview.html", pageData{ + User: &db.User{Name: "alice"}, Prefix: "/" + testSecret, Lang: "en", + BandwidthQuotaGB: 100, BandwidthUsedGB: "1.0", + BandwidthResetDay: c.day, BandwidthResetThisMonth: c.thisMonth, + }) + if !strings.Contains(en, c.wantEn) { + t.Errorf("%s: en label missing %q", c.name, c.wantEn) + } + } +} + +// TestOverviewBandwidthResetMonthIsDerived checks the page data itself: the +// month flag must come from the reset day and today, not be assumed. The +// assertion is expressed against the same helper the page uses, so it holds +// whenever the suite runs (the reset day is pinned relative to today). +func TestOverviewBandwidthResetMonthIsDerived(t *testing.T) { + now := time.Now().UTC() + // A day that is always still ahead of today within the same month: today's + // day + 1 capped at 28, falling back to "reset day 1" mid-month on the 28th. + ahead := now.Day() + 1 + if ahead > 28 { + t.Skip("today is late in the month; the same-month case needs a reset day still ahead") + } + next := mgr.BandwidthNextReset(now, ahead) + if next.Month() != now.Month() { + t.Fatalf("reset day %d on the %dth should fall in this month, got %s", ahead, now.Day(), next.Format("2006-01-02")) + } + // A day just passed (today's day, capped) belongs to next month unless it + // is exactly today. + past := now.Day() - 1 + if past < 1 { + t.Skip("today is the 1st; no earlier day to test") + } + next = mgr.BandwidthNextReset(now, past) + if next.Month() == now.Month() { + t.Fatalf("reset day %d on the %dth should fall next month, got %s", past, now.Day(), next.Format("2006-01-02")) } } diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index ddaf948..ec4f4c1 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -304,7 +304,7 @@ footer a{color:var(--muted);text-decoration:underline}
{{if eq .Lang "zh"}}本月流量{{else}}Bandwidth{{end}} ↑ {{.UpGB}} / ↓ {{.DownGB}} GB{{if not .BandwidthQuotaGB}} · {{if eq .Lang "zh"}}无限制{{else}}unlimited{{end}}{{end}}
{{if .BandwidthQuotaGB}}
-
{{if eq .Lang "zh"}}流量配额(至次月{{.BandwidthResetDay}}号){{else}}Bandwidth quota (until day {{.BandwidthResetDay}} next month){{end}}{{.BandwidthUsedGB}} / {{.BandwidthQuotaGB}} GB{{if .Throttled}} · {{if eq .Lang "zh"}}限速中 1Mbps{{else}}limited to 1Mbps{{end}}{{end}}
+
{{if eq .Lang "zh"}}流量配额(至{{if .BandwidthResetThisMonth}}本月{{else}}次月{{end}}{{.BandwidthResetDay}}号){{else}}Bandwidth quota (until day {{.BandwidthResetDay}} {{if .BandwidthResetThisMonth}}this month{{else}}next month{{end}}){{end}}{{.BandwidthUsedGB}} / {{.BandwidthQuotaGB}} GB{{if .Throttled}} · {{if eq .Lang "zh"}}限速中 1Mbps{{else}}limited to 1Mbps{{end}}{{end}}
{{end}} diff --git a/src/main.go b/src/main.go index 002e696..5d72b96 100644 --- a/src/main.go +++ b/src/main.go @@ -1000,12 +1000,23 @@ func configSet(args []string) error { return fmt.Errorf("save config: %w", err) } if noApply { + if f.Apply == cfg.ApplyDestructive { + // --no-apply skips the confirmation above, so say it here instead: + // the discarded totals land at the NEXT panel restart, which would + // otherwise make the loss look like a separate event. Setting the + // value back before that restart avoids it entirely. + fmt.Printf("WARNING: %s is a destructive setting — applying it at the next panel restart discards every user's accumulated bandwidth totals (already-lost totals are not restored by setting it back).\n", key) + } fmt.Printf("%s saved (not applied).\n", key) return nil } switch f.Apply { - case cfg.ApplyRestart: + case cfg.ApplyRestart, cfg.ApplyDestructive: + // ApplyDestructive carries the same "restart the panel" mechanics; the + // difference is that the operator was warned in confirmApply that the + // change discards stored state (only panel.bandwidth_reset_day today), + // and it is stated again below so it cannot be missed. switch key { case "panel.listen": if !listenFree(c.Panel.Listen) { @@ -1019,6 +1030,8 @@ func configSet(args []string) error { if _, err := os.Stat(c.Panel.Key); err != nil { fmt.Printf("warning: key file %s does not exist — panel may fail to start\n", c.Panel.Key) } + case "panel.bandwidth_reset_day": + fmt.Printf("note: the monthly bandwidth period now starts on day %d — every user's accumulated traffic totals are discarded and counting restarts from the next sample.\n", c.Panel.BandwidthResetDay) } if err := restartPanel(); err != nil { fmt.Printf("%s saved, but the panel was NOT restarted: %v\n", key, err) @@ -1138,10 +1151,12 @@ func promptConfigValue(c *cfg.Config, f *cfg.Field) (value string, changed bool, // dangerousApply reports whether applying a field change is disruptive enough // to warrant a second confirmation: re-running `vps install` (regenerates -// firewall/routing/container wiring) or a live runtime toggle (net.v4_forward -// changes SSH/port/domain reachability of every container). +// firewall/routing/container wiring), a live runtime toggle (net.v4_forward +// changes SSH/port/domain reachability of every container), or a change that +// DISCARDS stored state (panel.bandwidth_reset_day zeroes every user's +// accumulated traffic totals). func dangerousApply(f *cfg.Field) bool { - return f.Apply == cfg.ApplyInstall || f.Apply == cfg.ApplyImmediate + return f.Apply == cfg.ApplyInstall || f.Apply == cfg.ApplyImmediate || f.Apply == cfg.ApplyDestructive } // confirmApply asks for a y/N confirmation before saving a change whose apply @@ -1151,6 +1166,15 @@ func confirmApply(c *cfg.Config, f *cfg.Field, key string) (bool, error) { switch f.Apply { case cfg.ApplyInstall: what = "re-runs `vps install`, regenerating the firewall / routing / container wiring" + case cfg.ApplyDestructive: + switch key { + case "panel.bandwidth_reset_day": + what = "restarts the panel AND DISCARDS every user's accumulated bandwidth totals — " + + "the monthly accounting period is derived from this day, so moving it starts a fresh period " + + "(any over-quota throttle is lifted as a result). This cannot be undone" + default: + what = "restarts the panel and discards stored state" + } case cfg.ApplyImmediate: switch key { case "net.v4_forward":