mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 08:44:14 +00:00
add a way to change crew tier on admin page
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user