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}}
+
+ {{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}}
+