feat(panel): compact machine labels in the group switcher

- label each switcher option "<label>-<cpu>c<mem>g<disk>g" (e.g. 2-4c8g40g)
  and drop the shared group-name prefix
- order members base-first then by numeric suffix (A, 1, 2, ... 10)
- tag the sticky-notes card title with the current machine on multi-container
  groups; single-container users keep a bare title
- add mgr.MachineSpecs and cover labels, order and the tag with tests

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
bluebluesodaandCommandCodeBot committed 2026-09-11 06:40:56 +00:00
1 parent 69d771d17d
commit 77b3fa927a
5 files changed
+109 -6

No files matched your search

+10
View File
@@ -3,6 +3,7 @@ package mgr
import (
"errors"
"regexp"
"strconv"
"strings"
"vpsmgr/internal/db"
@@ -19,6 +20,15 @@ func UserGroupLabel(name string) string {
return name[len(g.Parent)+1:]
}
// MachineSpecs renders a compact quota tag for the container switcher:
// "<cpu>c<mem>g<disk>g", e.g. "4c8g40g". CPU reuses FormatCPU (tenths of a
// core, so 0.5 stays fractional); memory is MiB rendered as GiB with a trimmed
// decimal (1024 -> "1", 512 -> "0.5"); disk is already GiB.
func MachineSpecs(cpuTenths, memMB, diskGB int) string {
mem := strconv.FormatFloat(float64(memMB)/1024, 'f', -1, 64)
return FormatCPU(cpuTenths) + "c" + mem + "g" + strconv.Itoa(diskGB) + "g"
}
type UserGroup struct {
Name string
Parent string
+16
View File
@@ -42,6 +42,22 @@ func TestUserGroupLabel(t *testing.T) {
}
}
func TestMachineSpecs(t *testing.T) {
for _, test := range []struct {
cpu, mem, disk int
want string
}{
{10, 1024, 10, "1c1g10g"},
{40, 8192, 40, "4c8g40g"},
{5, 512, 10, "0.5c0.5g10g"},
{20, 1536, 20, "2c1.5g20g"},
} {
if got := MachineSpecs(test.cpu, test.mem, test.disk); got != test.want {
t.Errorf("MachineSpecs(%d, %d, %d) = %q, want %q", test.cpu, test.mem, test.disk, got, test.want)
}
}
}
func TestUserGroupName(t *testing.T) {
if got := UserGroupName("alice-1"); got != "alice" {
t.Fatalf("UserGroupName(alice-1) = %q, want alice", got)
+36 -4
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -127,10 +128,18 @@ type sshKeyRow struct {
Active bool `json:"active"`
}
// groupMemberRow is one container in the user's group switcher. Label and Specs
// are precomputed because the template cannot call mgr helpers or convert MiB.
type groupMemberRow struct {
Name string // account name, used as the <option> value
Label string // "A" for the base account, else the numeric suffix
Specs string // compact quota tag, e.g. "4c8g40g"
}
type pageData struct {
Title string
User *db.User
GroupUsers []*db.User
GroupUsers []groupMemberRow
GroupIndex string
GroupCount int
State string
@@ -307,14 +316,37 @@ 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 })
// Order: base account ("A") first, then children by numeric suffix, so the
// switcher reads A, 1, 2, … 10 instead of the lexical A, 1, 10, 2.
sort.Slice(groupUsers, func(i, j int) bool {
gi, gj := mgr.ParseUserGroup(groupUsers[i].Name), mgr.ParseUserGroup(groupUsers[j].Name)
if gi.Child != gj.Child {
return !gi.Child
}
if gi.Child {
ni, _ := strconv.Atoi(groupUsers[i].Name[len(gi.Parent)+1:])
nj, _ := strconv.Atoi(groupUsers[j].Name[len(gj.Parent)+1:])
if ni != nj {
return ni < nj
}
}
return groupUsers[i].Name < groupUsers[j].Name
})
groupRows := make([]groupMemberRow, 0, len(groupUsers))
for _, m := range groupUsers {
groupRows = append(groupRows, groupMemberRow{
Name: m.Name,
Label: mgr.UserGroupLabel(m.Name),
Specs: mgr.MachineSpecs(m.CPU, m.MemMB, m.DiskGB),
})
}
groupIndex := mgr.UserGroupLabel(u.Name)
d := pageData{
Title: "VPS Manager",
User: u,
GroupUsers: groupUsers,
GroupUsers: groupRows,
GroupIndex: groupIndex,
GroupCount: len(groupUsers),
GroupCount: len(groupRows),
ThemeColor: u.Color,
PublicIP: s.cfg.DisplayIP(),
Prefix: s.prefix(),
+45
View File
@@ -1192,6 +1192,51 @@ func TestOverviewSnapshotModal(t *testing.T) {
}
}
// TestOverviewGroupSwitcherLabels verifies the multi-container switcher shows
// "<label>-<cpu>c<mem>g<disk>g" with the group-name prefix omitted and the base
// account first, and that the sticky-notes title tags the machine only when the
// group has more than one container.
func TestOverviewGroupSwitcherLabels(t *testing.T) {
srv, d := newTestServer(t)
h := srv.Handler()
prefix := "/" + testSecret
hash, _ := pw.Hash("pw")
// alice: 1 core / 1 GiB / 10 GiB; alice-1: 4 cores / 8 GiB / 40 GiB.
if _, err := d.CreateUser("alice", hash, "10.42.0.2", 1, 30001, 10000, 10, 1024, 10); err != nil {
t.Fatal(err)
}
if _, err := d.CreateUser("alice-1", hash, "10.42.0.3", 2, 30002, 10100, 40, 8192, 40); err != nil {
t.Fatal(err)
}
cookie := loginAndCookie(t, h, prefix, "alice", "pw")
body := doReq(t, h, http.MethodGet, prefix, nil, cookie).Body.String()
base, child := "A-1c1g10g", "1-4c8g40g"
for _, want := range []string{">" + base + "</option>", ">" + child + "</option>"} {
if !strings.Contains(body, want) {
t.Errorf("overview switcher missing %q", want)
}
}
if i, j := strings.Index(body, base), strings.Index(body, child); i < 0 || j < 0 || i > j {
t.Errorf("switcher order: base should precede child (base at %d, child at %d)", i, j)
}
if !strings.Contains(body, "Machine A") {
t.Error("notes card should tag the current machine on a multi-container group")
}
// A lone container is not a group: no switcher, no machine tag.
if _, err := d.CreateUser("bob", hash, "10.42.0.4", 3, 30003, 10200, 10, 1024, 10); err != nil {
t.Fatal(err)
}
cookie = loginAndCookie(t, h, prefix, "bob", "pw")
body = doReq(t, h, http.MethodGet, prefix, nil, cookie).Body.String()
if strings.Contains(body, "Machine A") {
t.Error("single-container user should not show a machine tag")
}
if strings.Contains(body, "switch-user") {
t.Error("single-container user should not render the switcher")
}
}
// TestStatsEndpoint verifies the /stats JSON endpoint: it requires auth,
// returns per-minute points for the current user, and computes per-minute
// bandwidth as the delta of the cumulative rx/tx counters between samples.
+2 -2
View File
@@ -234,7 +234,7 @@ footer a{color:var(--muted);text-decoration:underline}
</div>
{{if .Err}}<div class="err">{{.Err}}</div>{{end}}
{{if gt .GroupCount 1}}
<div class="groupbanner"><span>⚠ {{if eq .Lang "zh"}}您的用户组内有多台机器,当前机器序号为{{else}}Your user group has multiple containers. Current container number:{{end}} <strong class="container-index">{{.GroupIndex}}</strong>。{{if eq .Lang "zh"}}请确认您操作的机器无误。{{else}}Confirm this is the intended container before operating.{{end}}{{if gt (len .GroupUsers) 1}}<form action="{{.Prefix}}/switch-user" method="post" class="inline group-switch"><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}}</div>
<div class="groupbanner"><span>⚠ {{if eq .Lang "zh"}}您的用户组内有多台机器,当前机器序号为{{else}}Your user group has multiple containers. Current container number:{{end}} <strong class="container-index">{{.GroupIndex}}</strong>。{{if eq .Lang "zh"}}请确认您操作的机器无误。{{else}}Confirm this is the intended container before operating.{{end}}{{if gt (len .GroupUsers) 1}}<form action="{{.Prefix}}/switch-user" method="post" class="inline group-switch"><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}}>{{.Label}}-{{.Specs}}</option>{{end}}</select></form>{{end}}</div>
{{end}}
{{if .Impersonated}}
<div class="impbanner">
@@ -319,7 +319,7 @@ footer a{color:var(--muted);text-decoration:underline}
</div>
<div class="card">
<h2>{{if eq .Lang "zh"}}便利贴{{else}}Sticky notes{{end}}<span class="muted notes-status" id="notesStatus"></span></h2>
<h2>{{if eq .Lang "zh"}}便利贴{{else}}Sticky notes{{end}}{{if gt .GroupCount 1}}<span class="muted"> · {{if eq .Lang "zh"}}机器{{else}}Machine{{end}} {{.GroupIndex}}</span>{{end}}<span class="muted notes-status" id="notesStatus"></span></h2>
<div id="notesEnable" class="notes-cover" hidden>
<button class="btn accent" id="notesEnableBtn">{{if eq .Lang "zh"}}设置密码以启用便利贴{{else}}Set a password to enable notes{{end}}</button>