diff --git a/pkg/hold/admin/admin.go b/pkg/hold/admin/admin.go index 29c35ba..3c02921 100644 --- a/pkg/hold/admin/admin.go +++ b/pkg/hold/admin/admin.go @@ -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) diff --git a/pkg/hold/admin/handlers_crew.go b/pkg/hold/admin/handlers_crew.go index 4c0fa53..41bb1e4 100644 --- a/pkg/hold/admin/handlers_crew.go +++ b/pkg/hold/admin/handlers_crew.go @@ -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() diff --git a/pkg/hold/admin/templates/partials/tab_crew.html b/pkg/hold/admin/templates/partials/tab_crew.html index 66d82a6..cb123b6 100644 --- a/pkg/hold/admin/templates/partials/tab_crew.html +++ b/pkg/hold/admin/templates/partials/tab_crew.html @@ -19,6 +19,59 @@ +{{if .StaleTiers}} +
+
+
+ +
+

Tier reconciliation needed

+

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

+
+
+
+ + + + + + + + + + + + {{range .StaleTiers}} + + + + + + {{end}} + +
Stale tier names
Stored tier nameCrewRemap to
{{.Name}}{{.Count}} +
+ {{ csrfInput $.CSRFToken }} + + + +
+
+
+

+ Tip: individual crew can also be remapped via the Edit action on their row below. +

+
+
+{{end}} + {{if .Crew}}