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:
1 parent
69d771d17d
commit
77b3fa927a
5 files changed
+109
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user