diff --git a/src/internal/admin/admin.go b/src/internal/admin/admin.go index 4e79210..396f1df 100644 --- a/src/internal/admin/admin.go +++ b/src/internal/admin/admin.go @@ -77,6 +77,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/power", s.requireAuth(s.requirePost(s.handlePower))) mux.HandleFunc("/reset-panel-pass", s.requireAuth(s.requirePost(s.handleResetPanelPass))) mux.HandleFunc("/admin-pass", s.requireAuth(s.requirePost(s.handleAdminPass))) + mux.HandleFunc("/cpu-limit", s.requireAuth(s.requirePost(s.handleCPULimitRule))) mux.HandleFunc("/keys", s.requireAuth(s.requirePost(s.handleAdminKeys))) mux.HandleFunc("/login-as", s.requireAuth(s.requirePost(s.handleLoginAs))) mux.HandleFunc("/flash", s.requireAuth(s.requirePost(s.handleFlash))) diff --git a/src/internal/admin/handlers.go b/src/internal/admin/handlers.go index 95d4b75..31f0448 100644 --- a/src/internal/admin/handlers.go +++ b/src/internal/admin/handlers.go @@ -2,7 +2,9 @@ package admin import ( "encoding/json" + "fmt" "net/http" + "sort" "strconv" "strings" "time" @@ -48,6 +50,23 @@ type pageData struct { PoolTotal int // AdminKeys is the operator's own SSH-key store (management panel only). AdminKeys []sshKeyRow + // Global dynamic CPU limit rule, prefilled into the overview form. The + // Active list is the containers currently capped by it. + CPULimitEnabled bool + CPULimitMinutes int + CPULimitPercent int + CPULimitCores string + CPULimitHours int + CPULimitDurMin int + CPULimitActive []cpuLimitRow +} + +// cpuLimitRow is one container currently under the dynamic CPU limit, shown in +// the rule card (name, the cap, and when it expires). +type cpuLimitRow struct { + Name string + Cores string + Expires string } // sshKeyRow is one public key shown in the admin SSH-key management panel. @@ -145,9 +164,49 @@ func (s *Server) buildPageData(msg, errMsg string) pageData { d.AdminKeys = append(d.AdminKeys, sshKeyRow{ID: k.ID, Name: k.Name, Key: k.Key, Active: k.Active}) } } + if rule, err := s.mgr.CPULimitRule(); err == nil { + d.CPULimitEnabled = rule.Enabled + d.CPULimitMinutes = rule.WindowMinutes + d.CPULimitPercent = rule.Percent + d.CPULimitCores = mgr.FormatCPU(rule.CoresX10) + d.CPULimitHours = rule.DurationSeconds / 3600 + d.CPULimitDurMin = (rule.DurationSeconds % 3600) / 60 + } + nowUnix := time.Now().Unix() + for name, st := range s.mgr.CPULimits() { + if st.Until <= nowUnix { + continue + } + d.CPULimitActive = append(d.CPULimitActive, cpuLimitRow{ + Name: name, + Cores: mgr.FormatCPU(st.CoresX10), + Expires: fmtCPURemaining(time.Unix(st.Until, 0)), + }) + } + sort.Slice(d.CPULimitActive, func(i, j int) bool { return d.CPULimitActive[i].Name < d.CPULimitActive[j].Name }) return d } +// fmtCPURemaining renders the time left on a dynamic CPU limit compactly +// ("2h30m", "5m12s", "40s"). +func fmtCPURemaining(until time.Time) string { + d := time.Until(until).Truncate(time.Second) + if d < 0 { + d = 0 + } + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + switch { + case h > 0: + return fmt.Sprintf("%dh%dm", h, m) + case m > 0: + return fmt.Sprintf("%dm%ds", m, s) + default: + return fmt.Sprintf("%ds", s) + } +} + func (s *Server) loadUsers(d *pageData) { statuses, err := s.mgr.BatchUsers() if err != nil { @@ -592,6 +651,64 @@ func (s *Server) handleAdminPass(w http.ResponseWriter, r *http.Request) { s.redirect(w, r, s.p(""), s.t(r, "admin_pass_changed")) } +// handleCPULimitRule saves the global dynamic CPU limit rule and applies it +// immediately: a disabled rule restores every capped container right away +// instead of waiting for the next 60s pass. +func (s *Server) handleCPULimitRule(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 400) + return + } + enabled := r.FormValue("enabled") != "" + minutes, err := strconv.Atoi(strings.TrimSpace(r.FormValue("window_minutes"))) + if err != nil || minutes < 1 { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_window")) + return + } + percent, err := strconv.Atoi(strings.TrimSpace(r.FormValue("percent"))) + if err != nil || percent < 1 || percent > 100 { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_percent")) + return + } + cores, err := mgr.ParseLimitCores(r.FormValue("cores")) + if err != nil { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_cores")) + return + } + hours, err := strconv.Atoi(strings.TrimSpace(r.FormValue("hours"))) + if err != nil || hours < 0 { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_duration")) + return + } + durMin, err := strconv.Atoi(strings.TrimSpace(r.FormValue("dur_minutes"))) + if err != nil || durMin < 0 || durMin > 59 { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_duration")) + return + } + duration := hours*3600 + durMin*60 + if enabled && duration <= 0 { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_duration")) + return + } + rule := mgr.CPULimitRule{ + Enabled: enabled, + WindowMinutes: minutes, + Percent: percent, + CoresX10: cores, + DurationSeconds: duration, + } + if err := s.mgr.SetCPULimitRule(rule); err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + if err := s.mgr.EnforceCPULimits(); err != nil { + s.redirect(w, r, s.p(""), "error: "+err.Error()) + return + } + _ = s.db.AddAuditLog("000", "cpu_limit.update") + s.redirect(w, r, s.p(""), s.t(r, "cpu_limit_saved")) +} + // handleLoginAs ("log in as user" / impersonation) creates a user-panel // session for the given username and hands the browser its cookie, dropping the // operator straight into that user's panel — regardless of the user's password. diff --git a/src/internal/admin/i18n.go b/src/internal/admin/i18n.go index 5350847..b1bbed3 100644 --- a/src/internal/admin/i18n.go +++ b/src/internal/admin/i18n.go @@ -79,29 +79,34 @@ func (s *Server) t(r *http.Request, key string, args ...any) string { // stable identifiers; args are %-formatted into the chosen language's template. func tr(l, key string, args ...any) string { m := map[string][2]string{ // key -> [zh, en] - "err_bad_login": {"管理员密码错误", "invalid admin password"}, - "err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"}, - "err_not_configured": {"管理员尚未初始化 — 请运行 `vps admin-passwd`", "admin not configured yet — run `vps admin-passwd`"}, - "err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"}, - "err_pass_short": {"密码长度不得低于 10 位", "password must be at least 10 characters"}, - "err_pass_weak": {"密码需同时包含大写字母、小写字母和数字", "password must contain uppercase letters, lowercase letters and digits"}, - "user_created": {"用户已创建:\n%[1]v", "user created:\n%[1]v"}, - "user_deleted": {"用户 %[1]v 已删除", "user %[1]v deleted"}, - "quota_updated": {"已更新 %[1]v 的配额", "quotas updated for %[1]v"}, - "bandwidth_reset": {"已重置 %[1]v 的流量统计(若曾限速则已解除)", "bandwidth reset for %[1]v (throttle lifted if active)"}, - "power_ok": {"%[1]v %[2]v 成功", "%[2]v %[1]v ok"}, - "new_panel_password": {"用户 %[1]v 的面板密码已重置:\n%[2]v\n面板:%[3]v", "%[1]v panel password reset:\n%[2]v\npanel: %[3]v"}, - "admin_pass_changed": {"管理员密码已修改", "admin password changed"}, - "err_invalid_disk": {"磁盘必须是整数 GiB", "disk must be an integer (GiB)"}, - "domain_deleted": {"域名 %[1]v 已删除", "domain %[1]v deleted"}, - "domains_updated": {"域名设置已保存", "domain settings saved"}, - "blocked_saved": {"封禁域名列表已保存", "blocked-domains list saved"}, - "blocked_skipped_lines": {"第 %[1]v 行不是有效域名,已跳过;其余已保存", "line(s) %[1]v are not valid domains and were skipped; the rest were saved"}, - "pool_added": {"已添加 %[1]v 个 IPv6 地址到池", "added %[1]v IPv6 address(es) to the pool"}, - "pool_removed": {"已从池中移除 %[1]v", "removed %[1]v from the pool"}, - "color_updated": {"已设置 %[1]v 的专属颜色", "color set for %[1]v"}, - "color_reset": {"已清除 %[1]v 的专属颜色", "color cleared for %[1]v"}, - "err_invalid_color": {"颜色不在可选范围内", "color is not in the allowed palette"}, + "err_bad_login": {"管理员密码错误", "invalid admin password"}, + "err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"}, + "err_not_configured": {"管理员尚未初始化 — 请运行 `vps admin-passwd`", "admin not configured yet — run `vps admin-passwd`"}, + "err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"}, + "err_pass_short": {"密码长度不得低于 10 位", "password must be at least 10 characters"}, + "err_pass_weak": {"密码需同时包含大写字母、小写字母和数字", "password must contain uppercase letters, lowercase letters and digits"}, + "user_created": {"用户已创建:\n%[1]v", "user created:\n%[1]v"}, + "user_deleted": {"用户 %[1]v 已删除", "user %[1]v deleted"}, + "quota_updated": {"已更新 %[1]v 的配额", "quotas updated for %[1]v"}, + "bandwidth_reset": {"已重置 %[1]v 的流量统计(若曾限速则已解除)", "bandwidth reset for %[1]v (throttle lifted if active)"}, + "power_ok": {"%[1]v %[2]v 成功", "%[2]v %[1]v ok"}, + "new_panel_password": {"用户 %[1]v 的面板密码已重置:\n%[2]v\n面板:%[3]v", "%[1]v panel password reset:\n%[2]v\npanel: %[3]v"}, + "admin_pass_changed": {"管理员密码已修改", "admin password changed"}, + "err_invalid_disk": {"磁盘必须是整数 GiB", "disk must be an integer (GiB)"}, + "domain_deleted": {"域名 %[1]v 已删除", "domain %[1]v deleted"}, + "domains_updated": {"域名设置已保存", "domain settings saved"}, + "blocked_saved": {"封禁域名列表已保存", "blocked-domains list saved"}, + "blocked_skipped_lines": {"第 %[1]v 行不是有效域名,已跳过;其余已保存", "line(s) %[1]v are not valid domains and were skipped; the rest were saved"}, + "pool_added": {"已添加 %[1]v 个 IPv6 地址到池", "added %[1]v IPv6 address(es) to the pool"}, + "pool_removed": {"已从池中移除 %[1]v", "removed %[1]v from the pool"}, + "color_updated": {"已设置 %[1]v 的专属颜色", "color set for %[1]v"}, + "color_reset": {"已清除 %[1]v 的专属颜色", "color cleared for %[1]v"}, + "err_invalid_color": {"颜色不在可选范围内", "color is not in the allowed palette"}, + "cpu_limit_saved": {"CPU 动态限制规则已保存", "dynamic CPU limit rule saved"}, + "err_cpu_limit_window": {"连续时长必须是 ≥1 的整数分钟", "window must be an integer >= 1 minute"}, + "err_cpu_limit_percent": {"百分比必须是 1..100 的整数", "percent must be an integer between 1 and 100"}, + "err_cpu_limit_cores": {"限制核心数必须是 0.1..1.0(步长 0.1)", "cores must be a one-decimal value in 0.1..1.0"}, + "err_cpu_limit_duration": {"时长必须有效:小时 ≥0,分钟 0..59,且启用时总时长 >0", "duration must be valid: hours >= 0, minutes 0..59, and > 0 when enabled"}, } pair, ok := m[key] if !ok { diff --git a/src/internal/admin/templates/admin_overview.html b/src/internal/admin/templates/admin_overview.html index 72463a1..54ed949 100644 --- a/src/internal/admin/templates/admin_overview.html +++ b/src/internal/admin/templates/admin_overview.html @@ -98,6 +98,7 @@ form.inline[hidden] { display: none; } .field small.hint-bad { color: #b91c1c; } @media (prefers-color-scheme: dark) { .field small.hint-bad { color: #f87171; } } #adminPassForm .pwcheck { grid-column: 1 / -1; margin: -2px 0 0; } +#cpuLimitForm .cpu-limit-off input[type="number"] { opacity: .5; } .pwcheck { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12px; color: #6b7280; } @media (prefers-color-scheme: dark) { .pwcheck { color: #9aa0a8; } } .pwcheck li { display: inline-block; margin: 2px 14px 2px 0; } @@ -306,6 +307,35 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; } + +
+

{{if eq .Lang "zh"}}CPU 动态限制规则(全局){{else}}Dynamic CPU limit rule (global){{end}}

+
+
+ +
+
+
2.4 cores for a minute{{end}}">
+
+
+
+ +
+ {{if eq .Lang "zh"}}容器连续 x 分钟占用其配额超过 y% 时,限制到 z 核,持续 n 小时 m 分钟后自动恢复原配额。配额本身不高于 z 的容器会被跳过。{{else}}When a container stays above y% of its quota for x minutes, it is capped to z cores for n hours m minutes, then its quota is restored. Containers whose own quota is at or below z are skipped.{{end}} +
+ {{if .CPULimitActive}} +
{{if eq .Lang "zh"}}当前被限制容器{{else}}Currently limited containers{{end}}({{len .CPULimitActive}})
+ + {{range .CPULimitActive}}{{end}} +
{{if eq .Lang "zh"}}容器{{else}}Container{{end}}{{if eq .Lang "zh"}}限制到{{else}}Limited to{{end}}{{if eq .Lang "zh"}}剩余{{else}}Remaining{{end}}
{{.Name}}{{.Cores}} {{if eq $.Lang "zh"}}核{{else}}core(s){{end}}{{.Expires}}
+ {{else}} + {{if eq .Lang "zh"}}当前没有容器被限制{{else}}No container is currently limited{{end}} + {{end}} +
+
@@ -668,6 +698,22 @@ var child = low.match(/^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/); btn.disabled = true; btn.classList.add('busy'); }); })(); +// Dynamic CPU limit rule: dim the parameters while the rule is disabled (they +// stay enabled so their values are always submitted), and show a busy state on +// save. +(function(){ + var f = document.getElementById('cpuLimitForm'); + if (!f) return; + var en = document.getElementById('cpuLimitEnabled'); + function sync(){ f.classList.toggle('cpu-limit-off', !(en && en.checked)); } + if (en) en.addEventListener('change', sync); + sync(); + var btn = document.getElementById('cpuLimitBtn'); + f.addEventListener('submit', function(){ + if (!f.checkValidity()) return; + if (btn) { btn.disabled = true; btn.classList.add('busy'); } + }); +})(); // Quota update can take 10s+ (live LXD config changes); show a spinner on the // Save button while the request is in flight so the modal does not look dead. (function(){ diff --git a/src/internal/db/resource.go b/src/internal/db/resource.go index 5a98f96..809e57a 100644 --- a/src/internal/db/resource.go +++ b/src/internal/db/resource.go @@ -186,6 +186,37 @@ func (d *DB) AverageCPU(since int64) (map[int64]float64, error) { return out, rows.Err() } +// RecentResourceSamples returns every user's samples at or after the given +// minute, ordered by (user_id, sample_minute). One query feeds the dynamic CPU +// limit check, so it can reconstruct each container's trailing run of minutes +// over the quota without a per-user round trip. +func (d *DB) RecentResourceSamples(since int64) ([]ResourceSample, error) { + rows, err := d.sql.Query(` + SELECT user_id, sample_minute, state, boot_time, + cpu_seconds_ns, cpu_pct_x10, memory_mib, processes, + disk_used_mib, rx_bytes_total, tx_bytes_total, + disk_read_bytes_total, disk_write_bytes_total + FROM resource_samples + WHERE sample_minute >= ? + ORDER BY user_id, sample_minute`, since) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ResourceSample + for rows.Next() { + var s ResourceSample + if err := rows.Scan(&s.UserID, &s.SampleMinute, &s.State, &s.BootTime, + &s.CPUSecondsNS, &s.CPUPercentX10, &s.MemoryMiB, &s.Processes, + &s.DiskUsedMiB, &s.RXBytesTotal, &s.TXBytesTotal, + &s.DiskReadBytes, &s.DiskWrittenBytes); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + // ResourceHistory returns samples for a future resource chart. func (d *DB) ResourceHistory(userID, since int64) ([]ResourceSample, error) { rows, err := d.sql.Query(` diff --git a/src/internal/db/resource_test.go b/src/internal/db/resource_test.go index ad00351..ca46300 100644 --- a/src/internal/db/resource_test.go +++ b/src/internal/db/resource_test.go @@ -49,3 +49,44 @@ func TestRecordResourceSamplesAndRetention(t *testing.T) { t.Fatalf("history = %+v, err=%v", history, err) } } + +func TestRecentResourceSamples(t *testing.T) { + d, err := Open(t.TempDir() + "/recent.db") + if err != nil { + t.Fatal(err) + } + defer d.Close() + a, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 40, 1024, 10) + if err != nil { + t.Fatal(err) + } + b, err := d.CreateUser("bob", "h", "10.115.0.3", 2, 30002, 10100, 40, 1024, 10) + if err != nil { + t.Fatal(err) + } + sample := func(id, minute, cpu int64) ResourceSample { + return ResourceSample{UserID: id, SampleMinute: minute, State: 1, + BootTime: 100, CPUSecondsNS: minute * 1e9, CPUPercentX10: cpu} + } + // alice has samples at 0/60/120, bob only at 120; 0 is outside the window. + rows := []ResourceSample{sample(a.ID, 0, 100), sample(a.ID, 60, 200), sample(a.ID, 120, 300), sample(b.ID, 120, 400)} + if err := d.RecordResourceSamples(rows, nil, "2026-08", -60); err != nil { + t.Fatal(err) + } + got, err := d.RecentResourceSamples(60) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("RecentResourceSamples returned %d rows, want 3: %+v", len(got), got) + } + // Ordered by (user_id, sample_minute): alice's 60/120 then bob's 120. + if got[0].UserID != a.ID || got[0].SampleMinute != 60 || + got[1].UserID != a.ID || got[1].SampleMinute != 120 || + got[2].UserID != b.ID || got[2].SampleMinute != 120 { + t.Fatalf("unexpected order: %+v", got) + } + if got[2].CPUPercentX10 != 400 { + t.Fatalf("bob cpu pct = %d, want 400", got[2].CPUPercentX10) + } +} diff --git a/src/internal/db/settings.go b/src/internal/db/settings.go index 33b2746..6e7d2bf 100644 --- a/src/internal/db/settings.go +++ b/src/internal/db/settings.go @@ -38,6 +38,15 @@ const ( // newline-separated normalized domains. A blocked domain and all its // subdomains are refused by AddDomain (admin-managed via the web UI). SettingBlockedDomains = "blocked_domains" + + // SettingCPULimitRule is the admin-managed global dynamic CPU limit rule + // (JSON), applied to every container. Absent = the rule is disabled. + SettingCPULimitRule = "cpu_limit_rule" + + // SettingCPULimitActive is the JSON map of containers currently under a + // dynamic CPU limit (name -> {until, cores_x10}). Persisted so the limit + // survives a panel restart and the countdown stays accurate. + SettingCPULimitActive = "cpu_limit_active" ) // GetSetting returns a settings value; ok is false when the key is absent. diff --git a/src/internal/mgr/cpu.go b/src/internal/mgr/cpu.go index eab1b38..a2904a2 100644 --- a/src/internal/mgr/cpu.go +++ b/src/internal/mgr/cpu.go @@ -63,3 +63,23 @@ func FormatCPU(tenths int) string { } return "0." + strconv.Itoa(tenths) } + +// ParseLimitCores parses the dynamic CPU limit target z into tenths of a core. +// Unlike ParseCPU it is capped at one core: z is a time-slice limit, so it +// accepts any one-decimal value in 0.1..1.0 (e.g. "0.1", "0.5", "1", "1.0"), +// yielding 1..10 tenths. +func ParseLimitCores(s string) (int, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, errors.New("cores: empty input") + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, errors.New("cores must be a value in 0.1..1.0 (one-decimal steps)") + } + t := int(f*10 + 0.5) + if t < 1 || t > 10 || f*10-float64(t) > 1e-9 || float64(t)-f*10 > 1e-9 { + return 0, errors.New("cores must be a value in 0.1..1.0 (one-decimal steps)") + } + return t, nil +} diff --git a/src/internal/mgr/cpu_limit.go b/src/internal/mgr/cpu_limit.go new file mode 100644 index 0000000..472c13d --- /dev/null +++ b/src/internal/mgr/cpu_limit.go @@ -0,0 +1,265 @@ +package mgr + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "vpsmgr/internal/db" +) + +// CPULimitRule is the global dynamic CPU limit rule managed from the admin +// panel. It applies to every container: when a container keeps its CPU usage +// above Percent of its own quota for WindowMinutes in a row, it is capped to +// CoresX10 tenths of a core (the same time-slice semantics as a fractional +// quota) for DurationSeconds, then its normal quota is restored. +type CPULimitRule struct { + Enabled bool `json:"enabled"` + WindowMinutes int `json:"window_minutes"` // x: consecutive minutes over the threshold + Percent int `json:"percent"` // y: percent of the container's own quota, 1..100 + CoresX10 int `json:"cores_x10"` // z*10: limit target, 1..10 tenths (0.1..1.0 core) + DurationSeconds int `json:"duration_seconds"` // how long the limit lasts once applied +} + +// CPULimitState is the persisted state of one container's active dynamic limit. +type CPULimitState struct { + Until int64 `json:"until"` // unix seconds the limit expires + CoresX10 int `json:"cores_x10"` // the cap that was applied +} + +// DefaultCPULimitRule is the prefilled rule shown in the admin panel before +// anything is saved. It is disabled by default ("no limit"). +func DefaultCPULimitRule() CPULimitRule { + return CPULimitRule{ + Enabled: false, + WindowMinutes: 10, + Percent: 60, + CoresX10: 5, + DurationSeconds: 2*3600 + 30*60, // 2h30m + } +} + +// ValidateCPULimitRule rejects out-of-range rule parameters before they are +// persisted, so the enforcement loop only ever sees sane values. +func ValidateCPULimitRule(r CPULimitRule) error { + if r.WindowMinutes < 1 { + return errors.New("window must be at least 1 minute") + } + if r.Percent < 1 || r.Percent > 100 { + return errors.New("percent must be between 1 and 100") + } + if r.CoresX10 < 1 || r.CoresX10 > 10 { + return errors.New("cores must be between 0.1 and 1") + } + if r.Enabled && r.DurationSeconds <= 0 { + return errors.New("duration must be greater than 0 when the rule is enabled") + } + if r.DurationSeconds < 0 { + return errors.New("duration must not be negative") + } + return nil +} + +// CPULimitRule returns the stored rule, or the disabled default when unset. +func (m *Manager) CPULimitRule() (CPULimitRule, error) { + return m.loadCPULimitRule() +} + +// SetCPULimitRule validates and persists the rule. +func (m *Manager) SetCPULimitRule(r CPULimitRule) error { + if err := ValidateCPULimitRule(r); err != nil { + return err + } + b, err := json.Marshal(r) + if err != nil { + return err + } + return m.db.SetSetting(db.SettingCPULimitRule, string(b)) +} + +// CPULimits returns the containers currently carrying a dynamic CPU limit, +// keyed by container name. Safe to call from panel goroutines. +func (m *Manager) CPULimits() map[string]CPULimitState { + active, err := m.loadCPULimits() + if err != nil { + return map[string]CPULimitState{} + } + return active +} + +func (m *Manager) loadCPULimitRule() (CPULimitRule, error) { + v, ok, err := m.db.GetSetting(db.SettingCPULimitRule) + if err != nil { + return CPULimitRule{}, err + } + if !ok || v == "" { + return DefaultCPULimitRule(), nil + } + var r CPULimitRule + if err := json.Unmarshal([]byte(v), &r); err != nil { + return CPULimitRule{}, fmt.Errorf("parse cpu limit rule: %w", err) + } + return r, nil +} + +func (m *Manager) loadCPULimits() (map[string]CPULimitState, error) { + v, ok, err := m.db.GetSetting(db.SettingCPULimitActive) + if err != nil { + return nil, err + } + if !ok || v == "" { + return map[string]CPULimitState{}, nil + } + var active map[string]CPULimitState + if err := json.Unmarshal([]byte(v), &active); err != nil { + return nil, fmt.Errorf("parse active cpu limits: %w", err) + } + if active == nil { + active = map[string]CPULimitState{} + } + return active, nil +} + +func (m *Manager) saveCPULimits(active map[string]CPULimitState) error { + if active == nil { + active = map[string]CPULimitState{} + } + b, err := json.Marshal(active) + if err != nil { + return err + } + return m.db.SetSetting(db.SettingCPULimitActive, string(b)) +} + +// EnforceCPULimits applies and expires the dynamic CPU limit for every +// container. It restores the normal quota of containers whose limit expired, +// whose user was deleted, or while the rule is disabled, then caps any +// container that has been over Percent of its quota for WindowMinutes straight. +// Called by the 60s sampler (single goroutine) and by the admin handler on a +// rule change; cpuLimitMu serializes the two. Restoring uses the user's current +// DB quota (users.cpu), so an admin quota edit during a limit wins. +func (m *Manager) EnforceCPULimits() error { + m.cpuLimitMu.Lock() + defer m.cpuLimitMu.Unlock() + + rule, err := m.loadCPULimitRule() + if err != nil { + return err + } + active, err := m.loadCPULimits() + if err != nil { + return err + } + users, err := m.db.ListUsers() + if err != nil { + return err + } + byName := make(map[string]*db.User, len(users)) + for _, u := range users { + byName[u.Name] = u + } + + now := time.Now().Unix() + step := int64(ResourceSampleInterval / time.Second) + var firstErr error + changed := false + + // Restore/forget limits that expired, belong to a deleted user, or are + // orphaned by the rule being switched off. + for name, st := range active { + u := byName[name] + if u == nil { + delete(active, name) + changed = true + continue + } + if !rule.Enabled || now >= st.Until { + if err := m.lx.SetCPU(name, u.CPU); err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("restore cpu quota for %s: %w", name, err) + } + continue + } + delete(active, name) + changed = true + } + } + + // Apply the limit to containers that stayed over the threshold. + if rule.Enabled { + since := now - int64(rule.WindowMinutes+2)*step + samples, err := m.db.RecentResourceSamples(since) + if err != nil { + if firstErr == nil { + firstErr = err + } + } else { + grouped := make(map[int64][]db.ResourceSample) + for _, s := range samples { + grouped[s.UserID] = append(grouped[s.UserID], s) + } + threshold := int64(rule.Percent) * 10 + for _, u := range users { + if _, ok := active[u.Name]; ok { + continue + } + // A limit at or above the container's own quota would raise it + // instead of restricting it, so skip those. + if u.CPU <= rule.CoresX10 { + continue + } + if !cpuOverStreak(grouped[u.ID], now, rule.WindowMinutes, threshold) { + continue + } + if err := m.lx.SetCPU(u.Name, rule.CoresX10); err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("apply cpu limit to %s: %w", u.Name, err) + } + continue + } + active[u.Name] = CPULimitState{Until: now + int64(rule.DurationSeconds), CoresX10: rule.CoresX10} + changed = true + } + } + } + + if changed { + if err := m.saveCPULimits(active); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// cpuOverStreak reports whether the user's newest `need` samples form a +// contiguous run of running minutes, each above thresholdX10 (tenths of a +// percent of the container's own quota). The newest sample must be recent, so +// a sampling gap (panel down) never counts as sustained load. +func cpuOverStreak(samples []db.ResourceSample, now int64, need int, thresholdX10 int64) bool { + if need < 1 || len(samples) < need { + return false + } + last := samples[len(samples)-1] + if now-last.SampleMinute > 120 { + return false + } + step := int64(ResourceSampleInterval / time.Second) + expected := last.SampleMinute + count := 0 + for i := len(samples) - 1; i >= 0; i-- { + s := samples[i] + if s.SampleMinute != expected { + break + } + if s.State != resourceRunning || s.CPUPercentX10 <= thresholdX10 { + break + } + count++ + if count >= need { + return true + } + expected -= step + } + return false +} diff --git a/src/internal/mgr/cpu_limit_test.go b/src/internal/mgr/cpu_limit_test.go new file mode 100644 index 0000000..fc4bfb3 --- /dev/null +++ b/src/internal/mgr/cpu_limit_test.go @@ -0,0 +1,184 @@ +package mgr + +import ( + "encoding/json" + "path/filepath" + "testing" + + "vpsmgr/internal/cfg" + "vpsmgr/internal/db" +) + +func TestParseLimitCores(t *testing.T) { + valid := map[string]int{"0.1": 1, "0.5": 5, "0.9": 9, "1": 10, "1.0": 10, ".5": 5} + for in, want := range valid { + got, err := ParseLimitCores(in) + if err != nil { + t.Errorf("ParseLimitCores(%q) error: %v", in, err) + continue + } + if got != want { + t.Errorf("ParseLimitCores(%q) = %d, want %d", in, got, want) + } + } + invalid := []string{"", " ", "0", "0.0", "0.05", "1.1", "1.5", "2", "-0.5", "abc", "1e2"} + for _, in := range invalid { + if _, err := ParseLimitCores(in); err == nil { + t.Errorf("ParseLimitCores(%q) accepted, want error", in) + } + } +} + +func TestValidateCPULimitRule(t *testing.T) { + valid := []CPULimitRule{ + {Enabled: true, WindowMinutes: 1, Percent: 100, CoresX10: 1, DurationSeconds: 60}, + {Enabled: true, WindowMinutes: 10, Percent: 60, CoresX10: 10, DurationSeconds: 9000}, + {Enabled: false, WindowMinutes: 10, Percent: 60, CoresX10: 5, DurationSeconds: 0}, + } + for _, r := range valid { + if err := ValidateCPULimitRule(r); err != nil { + t.Errorf("ValidateCPULimitRule(%+v) error: %v", r, err) + } + } + invalid := []CPULimitRule{ + {Enabled: true, WindowMinutes: 0, Percent: 60, CoresX10: 5, DurationSeconds: 60}, + {Enabled: true, WindowMinutes: 10, Percent: 0, CoresX10: 5, DurationSeconds: 60}, + {Enabled: true, WindowMinutes: 10, Percent: 101, CoresX10: 5, DurationSeconds: 60}, + {Enabled: true, WindowMinutes: 10, Percent: 60, CoresX10: 0, DurationSeconds: 60}, + {Enabled: true, WindowMinutes: 10, Percent: 60, CoresX10: 11, DurationSeconds: 60}, + {Enabled: true, WindowMinutes: 10, Percent: 60, CoresX10: 5, DurationSeconds: 0}, + {Enabled: false, WindowMinutes: 10, Percent: 60, CoresX10: 5, DurationSeconds: -1}, + } + for _, r := range invalid { + if err := ValidateCPULimitRule(r); err == nil { + t.Errorf("ValidateCPULimitRule(%+v) accepted, want error", r) + } + } +} + +func TestCPULimitRuleRoundTrip(t *testing.T) { + d, err := db.Open(filepath.Join(t.TempDir(), "rule.db")) + if err != nil { + t.Fatal(err) + } + defer d.Close() + m := New(cfg.Default(), d) + + // Unset: the disabled prefilled default is returned. + got, err := m.CPULimitRule() + if err != nil { + t.Fatal(err) + } + if got != DefaultCPULimitRule() { + t.Fatalf("unset rule = %+v, want default %+v", got, DefaultCPULimitRule()) + } + want := CPULimitRule{Enabled: true, WindowMinutes: 3, Percent: 25, CoresX10: 2, DurationSeconds: 3600} + if err := m.SetCPULimitRule(want); err != nil { + t.Fatal(err) + } + got, err = m.CPULimitRule() + if err != nil || got != want { + t.Fatalf("round trip = %+v, err=%v, want %+v", got, err, want) + } + if err := m.SetCPULimitRule(CPULimitRule{Enabled: true, WindowMinutes: 0}); err == nil { + t.Fatal("invalid rule accepted") + } +} + +func TestCPUOverStreak(t *testing.T) { + const step = 60 + now := int64(6000) + running := func(min int64, pct int64) db.ResourceSample { + return db.ResourceSample{SampleMinute: min, State: 1, CPUPercentX10: pct} + } + cases := []struct { + name string + samples []db.ResourceSample + need int + thr int64 + want bool + }{ + {"three contiguous over", []db.ResourceSample{running(now-180, 700), running(now-120, 800), running(now-60, 900)}, 3, 600, true}, + {"newest not fresh", []db.ResourceSample{running(now-600, 700), running(now-540, 800), running(now-480, 900)}, 3, 600, false}, + {"gap breaks run", []db.ResourceSample{running(now-180, 700), running(now-120, 800), running(now, 900)}, 3, 600, false}, + {"equal threshold not over", []db.ResourceSample{running(now-120, 600), running(now-60, 600)}, 2, 600, false}, + {"stopped breaks run", []db.ResourceSample{running(now-120, 700), {SampleMinute: now - 60, State: 0, CPUPercentX10: 700}}, 2, 600, false}, + {"need more than present", []db.ResourceSample{running(now-60, 700)}, 3, 600, false}, + } + for _, tc := range cases { + if got := cpuOverStreak(tc.samples, now, tc.need, tc.thr); got != tc.want { + t.Errorf("%s: cpuOverStreak = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestEnforceCPULimitsDisabledNoActive(t *testing.T) { + d, err := db.Open(filepath.Join(t.TempDir(), "off.db")) + if err != nil { + t.Fatal(err) + } + defer d.Close() + if _, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 40, 1024, 10); err != nil { + t.Fatal(err) + } + c := cfg.Default() + c.Incus.Socket = "/nonexistent/vpsmgr-test.sock" + m := New(c, d) + + // Rule unset (disabled), nothing active: no Incus call, no error. + if err := m.EnforceCPULimits(); err != nil { + t.Fatalf("EnforceCPULimits with rule off: %v", err) + } +} + +func TestEnforceCPULimitsCleansDeletedUser(t *testing.T) { + d, err := db.Open(filepath.Join(t.TempDir(), "del.db")) + if err != nil { + t.Fatal(err) + } + defer d.Close() + c := cfg.Default() + c.Incus.Socket = "/nonexistent/vpsmgr-test.sock" + m := New(c, d) + + active := map[string]CPULimitState{"ghost": {Until: 1 << 40, CoresX10: 5}} + b, _ := json.Marshal(active) + if err := d.SetSetting(db.SettingCPULimitActive, string(b)); err != nil { + t.Fatal(err) + } + // The user does not exist, so the entry is dropped without touching Incus. + if err := m.EnforceCPULimits(); err != nil { + t.Fatalf("EnforceCPULimits: %v", err) + } + if len(m.CPULimits()) != 0 { + t.Fatalf("active limits = %+v, want empty", m.CPULimits()) + } +} + +func TestEnforceCPULimitsRestoreFailurePropagates(t *testing.T) { + d, err := db.Open(filepath.Join(t.TempDir(), "restore.db")) + if err != nil { + t.Fatal(err) + } + defer d.Close() + u, err := d.CreateUser("bob", "h", "10.115.0.3", 2, 30002, 10100, 40, 1024, 10) + if err != nil { + t.Fatal(err) + } + c := cfg.Default() + c.Incus.Socket = "/nonexistent/vpsmgr-test.sock" + m := New(c, d) + + active := map[string]CPULimitState{u.Name: {Until: 1, CoresX10: 5}} // already expired + b, _ := json.Marshal(active) + if err := d.SetSetting(db.SettingCPULimitActive, string(b)); err != nil { + t.Fatal(err) + } + if err := m.EnforceCPULimits(); err == nil { + t.Fatal("expected an error: restoring an expired limit must reach Incus") + } + // The restore failed, so the entry is kept for the next pass. + if _, ok := m.CPULimits()[u.Name]; !ok { + t.Fatal("active entry dropped despite a failed restore") + } +} diff --git a/src/internal/mgr/mgr.go b/src/internal/mgr/mgr.go index d8f503f..eadc34e 100644 --- a/src/internal/mgr/mgr.go +++ b/src/internal/mgr/mgr.go @@ -71,6 +71,13 @@ type Manager struct { // deliberately NOT held while Del removes domain files — Del already holds // opMu, and holding both would risk lock-order deadlock with AddDomain. domainMu sync.Mutex + + // cpuLimitMu serializes the dynamic CPU limit enforcement. It is held by + // the 60s sampler and by the admin handler when a rule change is applied + // immediately, so a rule toggle cannot race with the periodic pass. The + // active-limit state itself lives in the DB (SettingCPULimitActive), so a + // panel restart keeps every countdown and still restores on expiry. + cpuLimitMu sync.Mutex } func New(c *cfg.Config, d *db.DB) *Manager { diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index 35187d2..6f465eb 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -157,6 +157,8 @@ type pageData struct { 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 + CPULimited bool // under the admin's dynamic CPU limit + CPULimitUntil int64 // unix seconds the dynamic CPU limit expires Domains []domainRow QuotaCPU string QuotaMem string @@ -372,6 +374,13 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData { // Resource usage comes from the persisted sampler snapshot (five-minute // CPU average plus latest memory/disk), never from a live Incus sample. d.CPUUse, d.MemUse, d.DiskUsed = s.mgr.PanelResources(u.ID) + // Dynamic CPU limit (admin global rule): while capped, show the cap instead + // of the quota and hand the template an expiry for its live countdown. + if st, ok := s.mgr.CPULimits()[u.Name]; ok && st.Until > time.Now().Unix() { + d.QuotaCPU = mgr.FormatCPU(st.CoresX10) + d.CPULimited = true + d.CPULimitUntil = st.Until + } // One `incus list` call only for the container status (must be live). // Bandwidth is read from the DB — the background sampler writes it every 60s. st, err := s.mgr.State(u.Name) diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index 5c6cb00..afec05a 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -416,6 +416,33 @@ func TestOverviewShowsBandwidthQuota(t *testing.T) { } } +// TestOverviewShowsCPULimit verifies the dynamic CPU limit badge (capped core +// count plus the expiry the frontend counts down) renders while a limit is +// active and stays absent otherwise. +func TestOverviewShowsCPULimit(t *testing.T) { + srv, _ := newTestServer(t) + limited := srv.renderToString(t, "overview.html", pageData{ + User: &db.User{Name: "alice"}, + Prefix: "/" + testSecret, + QuotaCPU: "0.5", + CPULimited: true, + CPULimitUntil: 4102444800, + }) + for _, want := range []string{`id="cpuLimitPill"`, `data-until="4102444800"`, "0.5"} { + if !strings.Contains(limited, want) { + t.Errorf("limited overview missing %q", want) + } + } + plain := srv.renderToString(t, "overview.html", pageData{ + User: &db.User{Name: "alice"}, + Prefix: "/" + testSecret, + QuotaCPU: "4", + }) + if strings.Contains(plain, `id="cpuLimitPill"`) { + t.Error("unlimited overview should not render the CPU limit badge") + } +} + // TestOverviewThemeColor verifies the operator-assigned accent color is // injected as a CSS variable override block only when set: with a theme color // the overview carries the tinted background and recolored accents, without it diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index 2c5067b..5948912 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -257,7 +257,7 @@ footer a{color:var(--muted);text-decoration:underline}

{{if eq .Lang "zh"}}机器管理{{else}}Machine{{end}}{{if eq .State "Running"}}{{if eq .Lang "zh"}}运行中{{else}}Running{{end}}{{else}}{{.State}}{{end}}

-
CPU{{.QuotaCPU}}
+
CPU{{.QuotaCPU}}{{if .CPULimited}} {{if eq .Lang "zh"}}限制中{{else}}limited{{end}} {{end}}
{{if eq .Lang "zh"}}内存{{else}}Memory{{end}}{{.QuotaMem}}
{{if eq .Lang "zh"}}磁盘{{else}}Disk{{end}}{{.QuotaDisk}}
@@ -550,6 +550,24 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}