feat(panel): add grouped multi-container users

- group sibling users by the username-number convention
- synchronize group credentials, colors, and session rotation
- add group switching and destructive-action warnings
- keep group updates transactional and validate names server-side

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-09 07:21:02 +00:00
1 parent 6856e510a4
commit e6bf390da0
10 files changed
+433 -28

No files matched your search

+14 -6
View File
@@ -366,7 +366,11 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), 400)
return
}
name := r.FormValue("name")
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
if err := s.mgr.ValidateAddName(name, true); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
cpu, err := mgr.ParseCPU(r.FormValue("cpu"))
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
@@ -393,7 +397,7 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
if ipv6 == "auto" {
ipv6 = ""
}
res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6})
res, err := s.mgr.Add(name, mgr.AddOptions{CPU: cpu, MemMB: memMB, DiskGB: diskGB, BandwidthGB: bandwidthGB, IPv6Addr: ipv6, AllowChild: true})
if err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
@@ -402,9 +406,13 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
// The password is always auto-generated and shown once. The panel address
// is taken from the request's own Host (see panelURL), so it matches
// whatever origin the operator actually used to reach the admin panel.
cred := "user: " + res.User.Name +
"\npassword: " + res.Password +
"\npanel: " + s.panelURL(r, "/"+s.cfg.Panel.URLPath)
cred := "user: " + res.User.Name
if res.Password != "" {
cred += "\npassword: " + res.Password
} else {
cred += "\npassword: same as the existing user-group password"
}
cred += "\npanel: " + s.panelURL(r, "/"+s.cfg.Panel.URLPath)
// Carry the username as flash data so the modal can offer "log in as".
s.redirectModalData(w, r, s.p(""), cred, res.User.Name)
}
@@ -499,7 +507,7 @@ func (s *Server) handleUserColor(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), "error: user not found")
return
}
if err := s.db.UpdateUserColor(u.ID, color); err != nil {
if err := s.mgr.SetGroupColor(u.Name, color); err != nil {
s.redirect(w, r, s.p(""), "error: "+err.Error())
return
}
@@ -205,7 +205,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
<h2>{{if eq .Lang "zh"}}创建用户{{else}}Create user{{end}}</h2>
<div class="plans" id="plans"></div>
<form action="{{.Prefix}}/user-add" method="post" class="form-grid" id="createForm">
<div class="field"><label>{{if eq .Lang "zh"}}用户名{{else}}Username{{end}}</label><input name="name" id="cfName" pattern="[a-z]([a-z0-9-]*[a-z])?" maxlength="31" autocomplete="off" required title="{{if eq .Lang "zh"}}小写字母开头和结尾,仅含小写字母、数字和连字符{{else}}start/end with a lowercase letter; lowercase letters, digits, hyphens only{{end}}"><small id="cfNameHint" style="color:#6b7280;display:block;margin-top:4px;min-height:1em">{{if eq .Lang "zh"}}自动转为小写{{else}}Folded to lowercase{{end}}</small></div>
<div class="field"><label>{{if eq .Lang "zh"}}用户名{{else}}Username{{end}}</label><input name="name" id="cfName" pattern="[a-z]([a-z0-9-]*[a-z0-9])?" maxlength="31" autocomplete="off" required title="{{if eq .Lang "zh"}}小写字母开头和结尾,仅含小写字母、数字和连字符{{else}}start/end with a lowercase letter; lowercase letters, digits, hyphens only{{end}}"><small id="cfNameHint" style="color:#6b7280;display:block;margin-top:4px;min-height:1em">{{if eq .Lang "zh"}}自动转为小写{{else}}Folded to lowercase{{end}}</small></div>
<div class="field"><label>CPU</label>
<div class="cpu-row"><input name="cpu" id="cfCpu" type="number" min="0.1" step="any" value="1" required title="{{if eq .Lang "zh"}}整数核心(≥1)或 0.1~0.9 小数(单核的时间片占比){{else}}whole cores (>= 1) or a decimal 0.1..0.9 (a fraction of one core){{end}}"><span class="step"><button type="button" onclick="stepCpu('cfCpu',1)" title="+">▲</button><button type="button" onclick="stepCpu('cfCpu',-1)" title="−">▼</button></span></div>
</div>
@@ -580,7 +580,7 @@ function del(name){
var cpu = document.getElementById('cfCpu'), mem = document.getElementById('cfMem'), disk = document.getElementById('cfDisk'), bandwidth = document.getElementById('cfBandwidth'), btn = document.getElementById('cfBtn');
var nameIn = document.getElementById('cfName'), nameHint = document.getElementById('cfNameHint');
var existing = { {{range .Users}}"{{.Name}}":1, {{end}}};
var MSG = {{if eq .Lang "zh"}}{ok:'用户名可用', dup:'该用户名已存在', invalid:'小写字母开头和结尾,仅含小写字母、数字和连字符', hint:'自动转为小写'}{{else}}{ok:'Username available', dup:'Username already exists', invalid:'start/end with a lowercase letter; lowercase letters, digits, hyphens only', hint:'Folded to lowercase'}{{end}};
var MSG = {{if eq .Lang "zh"}}{ok:'用户名可用', group:'将创建用户组:', groupAfter:'(已有 ', groupEnd:' 个账号,创建后 ', accounts:' 个)', dup:'该用户名已存在', invalid:'主用户必须以字母结尾;子用户格式为 主用户-数字,且不允许嵌套', hint:'自动转为小写'}{{else}}{ok:'Username available', group:'User group to create: ', groupAfter:' (', groupEnd:' accounts, ', accounts:' after creation)', dup:'Username already exists', invalid:'parent must end with a letter; child format is parent-number, nesting is not allowed', hint:'Folded to lowercase'}{{end}};
function nameStatus(){
var raw = nameIn.value;
// Fold any uppercase to lowercase as the user types.
@@ -589,18 +589,27 @@ function del(name){
nameIn.setCustomValidity('');
nameHint.classList.remove('hint-ok', 'hint-bad');
if (!low) { nameHint.textContent = MSG.hint; nameIn.classList.remove('bad'); return; }
var okRe = /^[a-z]([a-z0-9-]*[a-z])?$/.test(low);
if (!okRe) {
nameIn.classList.add('bad');
nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.invalid;
nameIn.setCustomValidity(MSG.invalid);
var child = low.match(/^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/);
var parent = child ? child[1] : '';
var okRe = /^[a-z]([a-z0-9-]*[a-z])?$/.test(low) || !!child;
if (!okRe || (child && /^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/.test(parent))) {
nameIn.classList.add('bad');
nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.invalid;
nameIn.setCustomValidity(MSG.invalid);
} else if (existing[low]) {
nameIn.classList.add('bad');
nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.dup;
nameIn.setCustomValidity(MSG.dup);
nameIn.classList.add('bad');
nameHint.classList.add('hint-bad'); nameHint.textContent = MSG.dup;
nameIn.setCustomValidity(MSG.dup);
} else {
nameIn.classList.remove('bad');
nameHint.classList.add('hint-ok'); nameHint.textContent = MSG.ok;
nameIn.classList.remove('bad');
var count = 0;
var group = child ? parent : low;
Object.keys(existing).forEach(function(n){
var m = n.match(/^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/);
if (n === group || (m && m[1] === group)) count++;
});
nameHint.classList.add('hint-ok');
nameHint.textContent = child ? MSG.group + group + MSG.groupAfter + count + MSG.groupEnd + (count + 1) + MSG.accounts : (count ? MSG.group + group + MSG.groupAfter + count + MSG.groupEnd + (count + 1) + MSG.accounts : MSG.ok);
}
}
if (nameIn) nameIn.addEventListener('input', nameStatus);
+38
View File
@@ -0,0 +1,38 @@
package db
func (d *DB) UpdateUsersPasswordAndDeleteSessions(ids []int64, passHash, keepToken string) error {
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, id := range ids {
if _, err := tx.Exec(`UPDATE users SET pass_hash=? WHERE id=?`, passHash, id); err != nil {
return err
}
if keepToken == "" {
if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=?`, id); err != nil {
return err
}
continue
}
if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id=? AND token<>?`, id, hashToken(keepToken)); err != nil {
return err
}
}
return tx.Commit()
}
func (d *DB) UpdateUsersColor(ids []int64, color string) error {
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, id := range ids {
if _, err := tx.Exec(`UPDATE users SET color=? WHERE id=?`, color, id); err != nil {
return err
}
}
return tx.Commit()
}
+81
View File
@@ -0,0 +1,81 @@
package db
import (
"testing"
"vpsmgr/internal/pw"
)
func TestUpdateUsersColorAtomically(t *testing.T) {
d, err := Open(t.TempDir() + "/group.db")
if err != nil {
t.Fatal(err)
}
defer d.Close()
first, err := d.CreateUser("alice", "old-a", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10)
if err != nil {
t.Fatal(err)
}
second, err := d.CreateUser("alice-1", "old-b", "10.42.0.3", 2, 30002, 10100, 1, 1024, 10)
if err != nil {
t.Fatal(err)
}
if err := d.UpdateUsersColor([]int64{first.ID, second.ID}, "#16a34a"); err != nil {
t.Fatal(err)
}
for _, id := range []int64{first.ID, second.ID} {
u, err := d.GetUserByID(id)
if err != nil {
t.Fatal(err)
}
if u.Color != "#16a34a" {
t.Errorf("user %d color = %q, want #16a34a", id, u.Color)
}
}
}
func TestUpdateUsersPasswordAndDeleteSessions(t *testing.T) {
d, err := Open(t.TempDir() + "/group.db")
if err != nil {
t.Fatal(err)
}
defer d.Close()
first, err := d.CreateUser("alice", "old-a", "10.42.0.2", 1, 30001, 10000, 1, 1024, 10)
if err != nil {
t.Fatal(err)
}
second, err := d.CreateUser("alice-1", "old-b", "10.42.0.3", 2, 30002, 10100, 1, 1024, 10)
if err != nil {
t.Fatal(err)
}
keep, err := d.CreateSession(first.ID, 1)
if err != nil {
t.Fatal(err)
}
old, err := d.CreateSession(second.ID, 1)
if err != nil {
t.Fatal(err)
}
hash, err := pw.Hash("new-password")
if err != nil {
t.Fatal(err)
}
if err := d.UpdateUsersPasswordAndDeleteSessions([]int64{first.ID, second.ID}, hash, keep.Token); err != nil {
t.Fatal(err)
}
for _, id := range []int64{first.ID, second.ID} {
u, err := d.GetUserByID(id)
if err != nil {
t.Fatal(err)
}
if !pw.Verify(u.PassHash, "new-password") {
t.Errorf("user %d password was not updated", id)
}
}
if _, _, err := d.SessionWithFlag(keep.Token); err != nil {
t.Fatalf("kept session rejected: %v", err)
}
if _, _, err := d.SessionWithFlag(old.Token); err == nil {
t.Fatal("old sibling session remained valid")
}
}
+95
View File
@@ -0,0 +1,95 @@
package mgr
import (
"errors"
"regexp"
"strings"
"vpsmgr/internal/db"
)
var childNameRe = regexp.MustCompile(`^([a-z][a-z0-9-]*[a-z])-([0-9]+)$`)
var numericSuffixRe = regexp.MustCompile(`-[0-9]+$`)
type UserGroup struct {
Name string
Parent string
Child bool
}
func ParseUserGroup(name string) UserGroup {
name = strings.ToLower(name)
m := childNameRe.FindStringSubmatch(name)
if len(m) == 0 || childNameRe.MatchString(m[1]) {
return UserGroup{Name: name}
}
return UserGroup{Name: name, Parent: m[1], Child: true}
}
func UserGroupName(name string) string {
g := ParseUserGroup(name)
if g.Child {
return g.Parent
}
return g.Name
}
func (m *Manager) ValidateAddName(name string, allowChild bool) error {
name = strings.ToLower(name)
g := ParseUserGroup(name)
if !g.Child {
if numericSuffixRe.MatchString(name) {
return errors.New("invalid child username: text before -number must start and end with a letter")
}
return ValidateName(name)
}
if !allowChild {
return errors.New("child users can only be created from the admin panel")
}
if len(name) > 31 {
return errors.New("invalid child username: max 31 characters")
}
return nil
}
func (m *Manager) UsersInGroup(name string) ([]*db.User, error) {
group := UserGroupName(name)
users, err := m.db.ListUsers()
if err != nil {
return nil, err
}
out := make([]*db.User, 0)
for _, u := range users {
if UserGroupName(u.Name) == group {
out = append(out, u)
}
}
return out, nil
}
func (m *Manager) SetGroupColor(name, color string) error {
m.opMu.Lock()
defer m.opMu.Unlock()
users, err := m.UsersInGroup(name)
if err != nil {
return err
}
ids := make([]int64, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
return m.db.UpdateUsersColor(ids, color)
}
func (m *Manager) inheritGroupColorLocked(name string, excludeID int64) error {
users, err := m.UsersInGroup(name)
if err != nil {
return err
}
for _, u := range users {
if u.ID != excludeID && u.Color != "" {
return m.db.UpdateUsersColor([]int64{excludeID}, u.Color)
}
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package mgr
import (
"testing"
"vpsmgr/internal/db"
)
func TestParseUserGroup(t *testing.T) {
tests := []struct {
name string
parent string
child bool
}{
{name: "alice", parent: "", child: false},
{name: "alice-1", parent: "alice", child: true},
{name: "alice-12", parent: "alice", child: true},
{name: "my-box-3", parent: "my-box", child: true},
{name: "alice-1-2", parent: "", child: false},
{name: "alice1", parent: "", child: false},
}
for _, tt := range tests {
got := ParseUserGroup(tt.name)
if got.Parent != tt.parent || got.Child != tt.child {
t.Fatalf("ParseUserGroup(%q) = %+v, want parent=%q child=%v", tt.name, got, tt.parent, tt.child)
}
}
}
func TestUserGroupName(t *testing.T) {
if got := UserGroupName("alice-1"); got != "alice" {
t.Fatalf("UserGroupName(alice-1) = %q, want alice", got)
}
if got := UserGroupName("alice"); got != "alice" {
t.Fatalf("UserGroupName(alice) = %q, want alice", got)
}
}
func TestValidateAddName(t *testing.T) {
m := &Manager{}
for _, name := range []string{"alice", "alice-1", "alice-99"} {
if err := m.ValidateAddName(name, true); err != nil {
t.Errorf("ValidateAddName(%q) = %v, want nil", name, err)
}
}
for _, name := range []string{"alice-1-2", "alice--1", "-1"} {
if err := m.ValidateAddName(name, true); err == nil {
t.Errorf("ValidateAddName(%q) = nil, want error", name)
}
}
if err := m.ValidateAddName("alice-1", false); err == nil {
t.Error("ValidateAddName child with allowChild=false = nil, want error")
}
}
func TestUsersInGroup(t *testing.T) {
d := testDB(t)
for i, name := range []string{"alice", "alice-1", "alice-2", "bob"} {
if _, err := d.CreateUser(name, "hash", "10.42.0."+string(rune('2'+i)), i+1, 30001+i, 10000+i*100, 1, 1024, 10); err != nil {
t.Fatal(err)
}
}
m := &Manager{db: d}
users, err := m.UsersInGroup("alice-1")
if err != nil {
t.Fatal(err)
}
if len(users) != 3 {
t.Fatalf("UsersInGroup(alice-1) returned %d users, want 3", len(users))
}
}
func testDB(t *testing.T) *db.DB {
t.Helper()
d, err := db.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return d
}
+36 -10
View File
@@ -316,9 +316,10 @@ systemctl restart ssh`
}
type AddOptions struct {
CPU int
MemMB int
DiskGB int
CPU int
MemMB int
DiskGB int
AllowChild bool
// BandwidthGB is the monthly bandwidth quota in GiB (0 = unlimited).
BandwidthGB int
// IPv6Addr is the pool-mode address to assign ("" = auto-pick the first
@@ -335,7 +336,7 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
// Usernames are case-insensitive: fold to lowercase before validating and
// checking for duplicates, so "Alice" and "alice" are the same user.
name = strings.ToLower(name)
if err := ValidateName(name); err != nil {
if err := m.ValidateAddName(name, opt.AllowChild); err != nil {
return nil, err
}
if err := ValidateCPU(opt.CPU); err != nil {
@@ -350,14 +351,27 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
if _, err := m.db.GetUserByName(name); err == nil {
return nil, errors.New("user already exists: " + name)
}
// Passwords are always generated ([a-zA-Z0-9]) — no user-supplied password
// is ever accepted, so nothing untrusted reaches the provisioning shell
// scripts.
// The panel password is shared by a user group. A new group gets a fresh
// password; a new member of an existing group reuses its hash. The generated
// plaintext is still used as the container root password, but is not exposed
// when the panel password was inherited.
pass := pw.Generate(20)
hash, err := pw.Hash(pass)
if err != nil {
return nil, err
}
inheritedPanelPassword := false
if groupUsers, err := m.UsersInGroup(name); err == nil {
for _, member := range groupUsers {
if member.PassHash != "" {
hash = member.PassHash
inheritedPanelPassword = true
break
}
}
} else {
return nil, err
}
// IPv4 slot: a random unused idx in 1..MaxUsers (the /24 host range). The
// port block is allocated independently from the configured user-port
// ranges (allocUserPortBlock) — the two are no longer coupled.
@@ -527,7 +541,12 @@ func (m *Manager) Add(name string, opt AddOptions) (*Result, error) {
m.db.UpdateUserStatus(u.ID, db.StatusFailed)
return nil, fmt.Errorf("db: mark user ready: %w", err)
}
return m.ResultFor(u, pass), nil
_ = m.inheritGroupColorLocked(u.Name, u.ID)
result := m.ResultFor(u, pass)
if inheritedPanelPassword {
result.Password = ""
}
return result, nil
}
// checkIPv6BlockCollision refuses a new container if its deterministic /112
@@ -1168,6 +1187,8 @@ func snapshotCreateTime(s lx.SnapshotInfo) time.Time {
// preserve (empty means none, so every session is dropped). Container root
// password is managed separately via ResetRootPassword.
func (m *Manager) ChangePanelPassword(name, pass, keepToken string) error {
m.opMu.Lock()
defer m.opMu.Unlock()
u, err := m.db.GetUserByName(name)
if err != nil {
return err
@@ -1176,10 +1197,15 @@ func (m *Manager) ChangePanelPassword(name, pass, keepToken string) error {
if err != nil {
return err
}
if err := m.db.UpdatePassword(u.ID, hash); err != nil {
members, err := m.UsersInGroup(u.Name)
if err != nil {
return err
}
return m.db.DeleteSessionsForUserExcept(u.ID, keepToken)
ids := make([]int64, 0, len(members))
for _, member := range members {
ids = append(ids, member.ID)
}
return m.db.UpdateUsersPasswordAndDeleteSessions(ids, hash, keepToken)
}
// ResetPanelPassword sets the panel login password to a new random 20-char
+40
View File
@@ -190,6 +190,46 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p("/login"), "")
}
func (s *Server) handleSwitchUser(w http.ResponseWriter, r *http.Request) {
current := s.currentUser(r)
targetName := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
members, err := s.mgr.UsersInGroup(current.Name)
if err != nil {
s.redirect(w, r, s.p("/"), "error: unable to switch account")
return
}
var target *db.User
for _, member := range members {
if member.Name == targetName {
target = member
break
}
}
if target == nil {
s.redirect(w, r, s.p("/"), "error: account is not in this user group")
return
}
oldToken := ""
if cookie, err := r.Cookie("vpsmgr_session"); err == nil {
oldToken = cookie.Value
}
sess, err := s.db.CreateSession(target.ID, s.cfg.Panel.SessionDays)
if err != nil {
s.redirect(w, r, s.p("/"), "error: unable to switch account")
return
}
if oldToken != "" {
if err := s.db.DeleteSession(oldToken); err != nil {
s.db.DeleteSession(sess.Token)
s.redirect(w, r, s.p("/"), "error: unable to switch account")
return
}
}
s.setSessionCookie(w, sess.Token)
_ = s.db.AddAuditLog(current.Name, "session.switch."+target.Name)
http.Redirect(w, r, s.p("/"), http.StatusFound)
}
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
d := s.buildData(u, "", "")
+17
View File
@@ -5,6 +5,7 @@ import (
"html/template"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
@@ -129,6 +130,9 @@ type sshKeyRow struct {
type pageData struct {
Title string
User *db.User
GroupUsers []*db.User
GroupIndex int
GroupCount int
State string
IP string
SSHPort int
@@ -179,6 +183,7 @@ func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/logout", s.requireAuth(s.requirePost(s.handleLogout)))
mux.HandleFunc("/switch-user", s.requireAuth(s.requirePost(s.handleSwitchUser)))
mux.HandleFunc("/", s.requireAuth(s.handleOverview))
mux.HandleFunc("/power", s.requireAuth(s.requirePost(s.handlePower)))
mux.HandleFunc("/reinstall", s.requireAuth(s.requirePost(s.handleReinstall)))
@@ -301,9 +306,21 @@ func (s *Server) redirectModal(w http.ResponseWriter, r *http.Request, path, msg
}
func (s *Server) buildData(u *db.User, msg, errMsg string) pageData {
groupUsers, _ := s.mgr.UsersInGroup(u.Name)
sort.Slice(groupUsers, func(i, j int) bool { return groupUsers[i].Name < groupUsers[j].Name })
groupIndex := 0
for i, member := range groupUsers {
if member.ID == u.ID {
groupIndex = i + 1
break
}
}
d := pageData{
Title: "VPS Manager",
User: u,
GroupUsers: groupUsers,
GroupIndex: groupIndex,
GroupCount: len(groupUsers),
ThemeColor: u.Color,
PublicIP: s.cfg.DisplayIP(),
Prefix: s.prefix(),
@@ -74,6 +74,8 @@ label{font-size:.84rem;color:var(--muted)}
.impbanner b{color:var(--violet-fg)}
.impbanner .impback{margin-left:auto}
.impbanner a{color:var(--accent);text-decoration:underline;font-weight:600}
.groupbanner{background:#fef3c7;color:#92400e;border:2px solid #f59e0b;padding:.65rem .85rem;border-radius:8px;margin-bottom:.8rem;font-size:.92rem;font-weight:700}
@media (prefers-color-scheme: dark){.groupbanner{background:#3b2f10;color:#fcd34d;border-color:#b45309}}
a{color:var(--accent);text-decoration:none}
.domtable{width:100%;border-collapse:collapse;font-size:.85rem;margin:.2rem 0 .6rem}
.domtable th,.domtable td{text-align:left;padding:.3rem .4rem;border-bottom:1px solid var(--border);vertical-align:middle}
@@ -223,12 +225,18 @@ footer a{color:var(--muted);text-decoration:underline}
<div class="top">
<h1>VPS Manager</h1>
<div class="actions">
{{if gt (len .GroupUsers) 1}}
<form action="{{.Prefix}}/switch-user" method="post" class="inline"><select name="name" onchange="this.form.submit()" aria-label="{{if eq .Lang "zh"}}切换容器{{else}}Switch container{{end}}">{{range .GroupUsers}}<option value="{{.Name}}"{{if eq .Name $.User.Name}} selected{{end}}>{{.Name}}</option>{{end}}</select></form>
{{end}}
{{if eq .Lang "zh"}}<a class="btn" href="{{.Prefix}}/?lang=en">English</a>{{else}}<a class="btn" href="{{.Prefix}}/?lang=zh">中文</a>{{end}}
<button class="btn" id="pwBtn">{{if eq .Lang "zh"}}改密{{else}}Change password{{end}}</button>
<form action="{{.Prefix}}/logout" method="post" class="inline"><button class="btn" type="submit">{{if eq .Lang "zh"}}退出{{else}}Log out{{end}}</button></form>
</div>
</div>
{{if .Err}}<div class="err">{{.Err}}</div>{{end}}
{{if gt .GroupCount 1}}
<div class="groupbanner">⚠ {{if eq .Lang "zh"}}您的用户组内有多个容器,当前容器序号为 {{.GroupIndex}} / {{.GroupCount}}({{.User.Name}})。请确认当前容器后再执行重装或快照操作。{{else}}Your user group has multiple containers. Current container: {{.GroupIndex}} / {{.GroupCount}} ({{.User.Name}}). Confirm the current container before reinstalling or using snapshots.{{end}}</div>
{{end}}
{{if .Impersonated}}
<div class="impbanner">
<b>{{if eq .Lang "zh"}}管理员代客登录中{{else}}Operator is logged in as you{{end}}</b>
@@ -442,6 +450,7 @@ footer a{color:var(--muted);text-decoration:underline}
<div class="overlay" id="snapModal">
<div class="modal modal-wide">
<h3>{{if eq .Lang "zh"}}时光机{{else}}Time machine{{end}}</h3>
{{if gt .GroupCount 1}}<div class="groupbanner">⚠ {{if eq .Lang "zh"}}当前容器序号:{{.GroupIndex}} / {{.GroupCount}}({{.User.Name}}){{else}}Current container: {{.GroupIndex}} / {{.GroupCount}} ({{.User.Name}}){{end}}</div>{{end}}
<p class="muted">{{if eq .Lang "zh"}}时光机只能回到过去,不能前往未来。恢复到某个检查点会丢失当前数据,以及在此之后创建的所有检查点。每容器最多 {{.SnapshotLimit}} 个检查点。{{else}}A time machine only goes back in time. Restoring to a checkpoint loses the current data and every checkpoint created after it. Up to {{.SnapshotLimit}} per container.{{end}}</p>
<div class="btnrow">
<form action="{{.Prefix}}/snapshot" method="post" class="inline"><button class="btn ok" type="submit" id="snapCreate">{{if eq .Lang "zh"}}创建检查点{{else}}Create checkpoint{{end}}</button></form>
@@ -519,6 +528,7 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
<div class="overlay" id="reModal">
<div class="modal modal-wide">
<h3>{{if eq .Lang "zh"}}重装系统{{else}}Reinstall{{end}}</h3>
{{if gt .GroupCount 1}}<div class="groupbanner">⚠ {{if eq .Lang "zh"}}当前容器序号:{{.GroupIndex}} / {{.GroupCount}}({{.User.Name}}){{else}}Current container: {{.GroupIndex}} / {{.GroupCount}} ({{.User.Name}}){{end}}</div>{{end}}
<p class="muted">{{if eq .Lang "zh"}}删除全部数据,以相同 IP/端口/配额重建。请选择要安装的系统:{{else}}Deletes all data and rebuilds with the same IP/ports/quota. Pick the system to install:{{end}}</p>
<form action="{{.Prefix}}/reinstall" method="post" id="reForm">
<input type="hidden" name="confirm" value="1">