add a way to change crew tier on admin page

This commit is contained in:
Evan Jarrett
2026-05-18 22:39:54 -05:00
parent ecd689a7e1
commit 2f02d3e7e5
3 changed files with 215 additions and 2 deletions
+1
View File
@@ -514,6 +514,7 @@ func (ui *AdminUI) RegisterRoutes(r chi.Router) {
r.Get("/admin/crew/{rkey}", ui.handleCrewEditForm)
r.Post("/admin/crew/{rkey}/update", ui.handleCrewUpdate)
r.Post("/admin/crew/{rkey}/delete", ui.handleCrewDelete)
r.Post("/admin/crew/remap-tier", ui.handleCrewRemapTier)
// Crew import/export
r.Get("/admin/crew/export", ui.handleCrewExport)
+161 -2
View File
@@ -2,6 +2,7 @@ package admin
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
@@ -51,6 +52,15 @@ type TierOption struct {
Limit string
}
// StaleTierGroup represents a tier name found on crew records that no longer
// exists in the current quota config. The crew column on the admin tab uses
// this to surface a reconciliation card so the operator can bulk-remap the
// affected crew to a current tier.
type StaleTierGroup struct {
Name string
Count int
}
// handleCrewTab returns the crew tab content (HTMX partial).
// Includes usage data (fast bulk SQL query) for correct sort order.
// Handles are lazy-loaded per-row via handleCrewMemberInfo.
@@ -117,10 +127,59 @@ func (ui *AdminUI) handleCrewTab(w http.ResponseWriter, r *http.Request) {
return crewViews[i].CurrentUsage > crewViews[j].CurrentUsage
})
// Detect crew records whose stored tier name no longer exists in the
// current quota config. Empty strings are skipped — those fall back to
// the default tier by design and aren't "stale". The result drives the
// reconciliation card at the top of the crew tab.
var staleTiers []StaleTierGroup
var tierOptions []TierOption
session := getSessionFromContext(r.Context())
csrfToken := ""
if session != nil {
csrfToken = session.CSRFToken
}
if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
validTiers := make(map[string]bool, ui.quotaMgr.TierCount())
for _, t := range ui.quotaMgr.ListTiers() {
validTiers[t.Key] = true
}
if len(validTiers) > 0 {
stale := make(map[string]int)
for _, member := range crew {
name := member.Record.Tier
if name == "" {
continue
}
if !validTiers[name] {
stale[name]++
}
}
if len(stale) > 0 {
staleTiers = make([]StaleTierGroup, 0, len(stale))
for name, count := range stale {
staleTiers = append(staleTiers, StaleTierGroup{Name: name, Count: count})
}
sort.Slice(staleTiers, func(i, j int) bool {
if staleTiers[i].Count != staleTiers[j].Count {
return staleTiers[i].Count > staleTiers[j].Count
}
return staleTiers[i].Name < staleTiers[j].Name
})
tierOptions = ui.getTierOptions()
}
}
}
data := struct {
Crew []CrewMemberView
Crew []CrewMemberView
StaleTiers []StaleTierGroup
Tiers []TierOption
CSRFToken string
}{
Crew: crewViews,
Crew: crewViews,
StaleTiers: staleTiers,
Tiers: tierOptions,
CSRFToken: csrfToken,
}
ui.renderTemplate(w, "partials/tab_crew.html", data)
}
@@ -392,6 +451,106 @@ func (ui *AdminUI) handleCrewUpdate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin#crew", http.StatusFound)
}
// handleCrewRemapTier bulk-rewrites every crew member whose stored Tier
// equals `from` to use `to` instead. Surfaced from the reconciliation card
// on the crew tab, which only appears when at least one stale tier name
// has been detected. CSRF, ownership, and method are gated by middleware.
//
// We deliberately do not require `from` to be a name that's currently
// unknown to the quota config: that would block re-running the action if
// an operator races a config edit, and it has no real safety benefit
// because `to` is still validated against the live tier list.
func (ui *AdminUI) handleCrewRemapTier(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
defer clearFlash(w)
if err := r.ParseForm(); err != nil {
setFlash(w, r, "error", "Invalid form data")
http.Redirect(w, r, "/admin#crew", http.StatusFound)
return
}
from := strings.TrimSpace(r.FormValue("from"))
to := strings.TrimSpace(r.FormValue("to"))
if from == "" || to == "" {
setFlash(w, r, "error", "Both source and target tier are required")
http.Redirect(w, r, "/admin#crew", http.StatusFound)
return
}
if ui.quotaMgr == nil || !ui.quotaMgr.IsEnabled() {
setFlash(w, r, "error", "Quotas are not enabled on this hold")
http.Redirect(w, r, "/admin#crew", http.StatusFound)
return
}
// Validate `to` against the live tier list — never write a name that
// doesn't resolve, that would just create a new stale group.
validTo := false
for _, t := range ui.quotaMgr.ListTiers() {
if t.Key == to {
validTo = true
break
}
}
if !validTo {
setFlash(w, r, "error", fmt.Sprintf("Unknown target tier %q", to))
http.Redirect(w, r, "/admin#crew", http.StatusFound)
return
}
members, err := ui.pds.ListCrewMembers(ctx)
if err != nil {
slog.Error("Failed to list crew members for tier remap", "error", err)
setFlash(w, r, "error", "Failed to load crew: "+err.Error())
http.Redirect(w, r, "/admin#crew", http.StatusFound)
return
}
var ok, failed int
for _, member := range members {
if member.Record.Tier != from {
continue
}
if err := ui.pds.UpdateCrewMemberTier(ctx, member.Record.Member, to); err != nil {
slog.Warn("Failed to remap crew tier",
"did", member.Record.Member,
"from", from,
"to", to,
"error", err)
failed++
continue
}
ok++
// Throttle firehose events. Matches the convention in
// pkg/hold/gc/gc.go (image config backfill).
time.Sleep(200 * time.Millisecond)
}
session := getSessionFromContext(ctx)
slog.Info("Bulk crew tier remap",
"from", from,
"to", to,
"updated", ok,
"failed", failed,
"by", func() string {
if session != nil {
return session.DID
}
return ""
}())
if ok == 0 && failed == 0 {
setFlash(w, r, "info", fmt.Sprintf("No crew were on tier %q", from))
} else if failed > 0 {
setFlash(w, r, "warning", fmt.Sprintf("Remapped %d crew from %q → %q (%d failed, see logs)", ok, from, to, failed))
} else {
setFlash(w, r, "success", fmt.Sprintf("Remapped %d crew from %q → %q", ok, from, to))
}
http.Redirect(w, r, "/admin#crew", http.StatusFound)
}
// handleCrewDelete removes a crew member
func (ui *AdminUI) handleCrewDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -19,6 +19,59 @@
</div>
</div>
{{if .StaleTiers}}
<div class="card bg-base-100 border border-warning/40 shadow-sm mb-6">
<div class="card-body p-6">
<div class="flex items-start gap-3 mb-4">
<span class="text-warning mt-0.5" aria-hidden="true">{{ icon "triangle-alert" "size-5" }}</span>
<div>
<h2 class="font-semibold text-base">Tier reconciliation needed</h2>
<p class="text-sm text-base-content/70 mt-1">
{{len .StaleTiers}} tier name{{if ne (len .StaleTiers) 1}}s{{end}} on crew records no longer exist in this hold's quota config. Affected crew fall back to the default tier at lookup time, but their stored tier name is stale. Pick a current tier to remap them.
</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<caption class="sr-only">Stale tier names</caption>
<thead>
<tr>
<th scope="col">Stored tier name</th>
<th scope="col" class="text-right">Crew</th>
<th scope="col">Remap to</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{{range .StaleTiers}}
<tr>
<td><code class="text-sm">{{.Name}}</code></td>
<td class="text-right tabular-nums">{{.Count}}</td>
<td colspan="2">
<form action="/admin/crew/remap-tier" method="POST" class="flex gap-2 items-center">
{{ csrfInput $.CSRFToken }}
<input type="hidden" name="from" value="{{.Name}}">
<select name="to" required class="select select-bordered select-sm w-full max-w-xs">
<option value="" disabled selected>Select tier…</option>
{{range $.Tiers}}
<option value="{{.Key}}">{{.Name}} ({{.Limit}})</option>
{{end}}
</select>
<button type="submit" class="btn btn-warning btn-sm">Apply</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="text-xs text-base-content/50 mt-3">
Tip: individual crew can also be remapped via the Edit action on their row below.
</p>
</div>
</div>
{{end}}
{{if .Crew}}
<div class="card bg-base-100 shadow-sm">
<div class="overflow-x-auto">