mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
The dashboard's top-users panel resolved a handle for every user with a
quota record, then sorted and truncated to ten. On a hold with ~500 crew
that is ~500 serial identity lookups to render ten rows.
Each lookup goes through the shared identity directory, whose HTTP client
allows 10s per request. One stalled lookup consumed the entire reverse
proxy budget, so the panel returned a partial body and the client hung up
mid-render:
admin/auth.go:161 "Failed to render template"
template=partials/top_users.html
error="write: broken pipe"
"GET /admin/api/top-users?limit=10" - 200 4096B in 10.005s
Sort and truncate first, then resolve only the surviving rows, so the
count is bounded by the limit rather than by hold size. Resolve those
concurrently under a 3s deadline: a slow lookup now degrades to a bare
DID instead of taking the whole request down with it.
The crew tab has the same underlying problem in a different shape, one
lazy-load request per row, and is not addressed here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
213 lines
5.6 KiB
Go
213 lines
5.6 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/go-chi/render"
|
|
)
|
|
|
|
// topUsersResolveTimeout bounds handle resolution on the dashboard's top-users
|
|
// panel. The shared identity directory allows each lookup 10s, which is the
|
|
// whole reverse proxy budget, so the panel caps its own resolution well short
|
|
// of that and renders bare DIDs for anything slower.
|
|
const topUsersResolveTimeout = 3 * time.Second
|
|
|
|
// DashboardStats contains dashboard statistics
|
|
type DashboardStats struct {
|
|
TotalCrewMembers int
|
|
TierDistribution map[string]int
|
|
}
|
|
|
|
// handleAdmin renders the single admin page with client-side tab switching
|
|
func (ui *AdminUI) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|
defer clearFlash(w)
|
|
ui.renderTemplate(w, "pages/admin.html", ui.newPageData(r, "Hold Admin", ""))
|
|
}
|
|
|
|
// getDashboardStats returns dashboard statistics
|
|
func (ui *AdminUI) getDashboardStats(ctx context.Context) DashboardStats {
|
|
crew, err := ui.pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
slog.Warn("Failed to list crew members for dashboard", "error", err)
|
|
}
|
|
|
|
stats := DashboardStats{
|
|
TotalCrewMembers: len(crew),
|
|
TierDistribution: make(map[string]int),
|
|
}
|
|
|
|
defaultTier := "default"
|
|
if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
|
|
defaultTier = ui.quotaMgr.GetDefaultTier()
|
|
}
|
|
|
|
for _, member := range crew {
|
|
tier := member.Record.Tier
|
|
if tier == "" {
|
|
tier = defaultTier
|
|
}
|
|
stats.TierDistribution[tier]++
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
// handleDashboardTab returns the dashboard tab content (HTMX partial)
|
|
func (ui *AdminUI) handleDashboardTab(w http.ResponseWriter, r *http.Request) {
|
|
defer clearFlash(w)
|
|
data := struct {
|
|
Stats DashboardStats
|
|
}{
|
|
Stats: ui.getDashboardStats(r.Context()),
|
|
}
|
|
ui.renderTemplate(w, "partials/tab_dashboard.html", data)
|
|
}
|
|
|
|
// StorageStats contains storage statistics
|
|
type StorageStats struct {
|
|
TotalBlobs int `json:"totalBlobs"`
|
|
TotalSize int64 `json:"totalSize"`
|
|
TotalHuman string `json:"totalHuman"`
|
|
UniqueDigests int `json:"uniqueDigests"`
|
|
}
|
|
|
|
// handleStatsAPI returns storage statistics (for HTMX lazy loading)
|
|
func (ui *AdminUI) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// Get layer record count from the index
|
|
recordsIndex := ui.pds.RecordsIndex()
|
|
if recordsIndex == nil {
|
|
http.Error(w, "Records index not available", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Count total layer records
|
|
totalBlobs, err := recordsIndex.Count(atproto.LayerCollection)
|
|
if err != nil {
|
|
slog.Error("Failed to count layer records", "error", err)
|
|
http.Error(w, "Failed to load stats", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Calculate total storage with a single bulk query
|
|
var totalSize int64
|
|
uniqueDigests := 0
|
|
|
|
allQuotas, err := ui.pds.GetAllUserQuotas(ctx)
|
|
if err != nil {
|
|
slog.Warn("Failed to get all user quotas", "error", err)
|
|
} else {
|
|
for _, q := range allQuotas {
|
|
totalSize += q.TotalSize
|
|
uniqueDigests += q.UniqueBlobs
|
|
}
|
|
}
|
|
|
|
stats := StorageStats{
|
|
TotalBlobs: totalBlobs,
|
|
TotalSize: totalSize,
|
|
TotalHuman: formatHumanBytes(totalSize),
|
|
UniqueDigests: uniqueDigests,
|
|
}
|
|
|
|
// If HTMX request, return HTML partial
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
data := struct {
|
|
Stats StorageStats
|
|
}{Stats: stats}
|
|
ui.renderTemplate(w, "partials/usage_stats.html", data)
|
|
return
|
|
}
|
|
|
|
// Otherwise return JSON
|
|
render.JSON(w, r, stats)
|
|
}
|
|
|
|
// UserUsage represents storage usage for a user
|
|
type UserUsage struct {
|
|
DID string `json:"did"`
|
|
Handle string `json:"handle"`
|
|
Usage int64 `json:"usage"`
|
|
UsageHuman string `json:"usageHuman"`
|
|
BlobCount int `json:"blobCount"`
|
|
}
|
|
|
|
// handleTopUsersAPI returns top users by storage (for HTMX lazy loading)
|
|
func (ui *AdminUI) handleTopUsersAPI(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
limit := 10
|
|
if l := r.URL.Query().Get("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
// Get all user quotas in a single bulk query
|
|
allQuotas, err := ui.pds.GetAllUserQuotas(ctx)
|
|
if err != nil {
|
|
slog.Error("Failed to get all user quotas", "error", err)
|
|
http.Error(w, "Failed to load top users", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var users []UserUsage
|
|
for did, q := range allQuotas {
|
|
users = append(users, UserUsage{
|
|
DID: did,
|
|
Usage: q.TotalSize,
|
|
UsageHuman: formatHumanBytes(q.TotalSize),
|
|
BlobCount: q.UniqueBlobs,
|
|
})
|
|
}
|
|
|
|
// Sort by usage (highest first)
|
|
sort.Slice(users, func(i, j int) bool {
|
|
return users[i].Usage > users[j].Usage
|
|
})
|
|
|
|
// Limit results
|
|
if len(users) > limit {
|
|
users = users[:limit]
|
|
}
|
|
|
|
// Resolve handles only for the rows that survived the limit. Doing this
|
|
// inside the loop above cost one network lookup per user on the hold to
|
|
// display ten of them, which blew past the identity directory's 10s HTTP
|
|
// timeout and left the client hanging up mid-render. Resolve concurrently
|
|
// under a deadline well inside the reverse proxy's budget so a stalled
|
|
// lookup degrades to a bare DID instead of a dead request.
|
|
resolveCtx, cancel := context.WithTimeout(ctx, topUsersResolveTimeout)
|
|
defer cancel()
|
|
|
|
var wg sync.WaitGroup
|
|
for i := range users {
|
|
wg.Add(1)
|
|
go func(u *UserUsage) {
|
|
defer wg.Done()
|
|
u.Handle = resolveHandle(resolveCtx, u.DID)
|
|
}(&users[i])
|
|
}
|
|
wg.Wait()
|
|
|
|
// If HTMX request, return HTML partial
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
data := struct {
|
|
Users []UserUsage
|
|
}{Users: users}
|
|
ui.renderTemplate(w, "partials/top_users.html", data)
|
|
return
|
|
}
|
|
|
|
// Otherwise return JSON
|
|
render.JSON(w, r, users)
|
|
}
|