fix(bandwidth): name the right month in the quota label; make the reset-day change explicit

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 <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-16 13:39:39 +00:00
1 parent 8d2d31041f
commit bbc90fbf74
10 files changed
+356 -46

No files matched your search

+26 -2
View File
@@ -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.<n>.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 <n>` 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
+10 -7
View File
@@ -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 {
+35
View File
@@ -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)
}
}
}
+48
View File
@@ -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)
+33
View File
@@ -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) {
+79
View File
@@ -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)
+37 -24
View File
@@ -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).
{
+59 -8
View File
@@ -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"))
}
}
+1 -1
View File
@@ -304,7 +304,7 @@ footer a{color:var(--muted);text-decoration:underline}
<div class="muted bandwidth-line">{{if eq .Lang "zh"}}本月流量{{else}}Bandwidth{{end}} ↑ {{.UpGB}} / ↓ {{.DownGB}} GB{{if not .BandwidthQuotaGB}} · {{if eq .Lang "zh"}}无限制{{else}}unlimited{{end}}{{end}}</div>
{{if .BandwidthQuotaGB}}
<div class="bandwidth-quota">
<div class="trow"><span class="muted">{{if eq .Lang "zh"}}流量配额(至次月{{.BandwidthResetDay}}号){{else}}Bandwidth quota (until day {{.BandwidthResetDay}} next month){{end}}</span><span>{{.BandwidthUsedGB}} / {{.BandwidthQuotaGB}} GB{{if .Throttled}} · <span class="pill down">{{if eq .Lang "zh"}}限速中 1Mbps{{else}}limited to 1Mbps{{end}}</span>{{end}}</span></div>
<div class="trow"><span class="muted">{{if eq .Lang "zh"}}流量配额(至{{if .BandwidthResetThisMonth}}本月{{else}}次月{{end}}{{.BandwidthResetDay}}号){{else}}Bandwidth quota (until day {{.BandwidthResetDay}} {{if .BandwidthResetThisMonth}}this month{{else}}next month{{end}}){{end}}</span><span>{{.BandwidthUsedGB}} / {{.BandwidthQuotaGB}} GB{{if .Throttled}} · <span class="pill down">{{if eq .Lang "zh"}}限速中 1Mbps{{else}}limited to 1Mbps{{end}}</span>{{end}}</span></div>
<div class="bar"><div class="fill" style="width:{{.BandwidthPct}}%"></div></div>
</div>
{{end}}
+28 -4
View File
@@ -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":