A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

lazy load crew membership in admin panel

+211 -94
+1
pkg/hold/admin/admin.go
··· 423 423 r.Get("/admin/api/stats", ui.handleStatsAPI) 424 424 r.Get("/admin/api/top-users", ui.handleTopUsersAPI) 425 425 r.Get("/admin/api/relay/status", ui.handleRelayStatus) 426 + r.Get("/admin/api/crew/member", ui.handleCrewMemberInfo) 426 427 427 428 // Logout 428 429 r.Post("/admin/auth/logout", ui.handleLogout)
+85 -53
pkg/hold/admin/handlers_crew.go
··· 9 9 "time" 10 10 11 11 "atcr.io/pkg/atproto" 12 - "atcr.io/pkg/hold/pds" 13 12 "github.com/go-chi/chi/v5" 14 13 ) 15 14 16 - // CrewMemberView represents a crew member for display 15 + // CrewMemberView represents a crew member for display (populated row) 17 16 type CrewMemberView struct { 18 17 RKey string 19 18 DID string ··· 28 27 AddedAt time.Time 29 28 } 30 29 30 + // CrewSkeletonView is the minimal crew member data for skeleton rendering. 31 + // Contains only data available from the MST walk (no network calls). 32 + type CrewSkeletonView struct { 33 + RKey string 34 + DID string 35 + Role string 36 + Permissions []string 37 + Tier string 38 + AddedAt time.Time 39 + } 40 + 31 41 // resolveHandle attempts to resolve a DID to a handle 32 42 // Returns empty string if resolution fails 33 43 func resolveHandle(ctx context.Context, did string) string { ··· 50 60 Limit string 51 61 } 52 62 53 - // getCrewViews builds the crew member view list 54 - func (ui *AdminUI) getCrewViews(ctx context.Context) ([]CrewMemberView, error) { 55 - crew, err := ui.pds.ListCrewMembers(ctx) 63 + // handleCrewTab returns the crew tab content (HTMX partial). 64 + // Only does the MST walk — no handle resolution or usage queries. 65 + // Each row lazy-loads its details via handleCrewMemberInfo. 66 + func (ui *AdminUI) handleCrewTab(w http.ResponseWriter, r *http.Request) { 67 + crew, err := ui.pds.ListCrewMembers(r.Context()) 56 68 if err != nil { 57 - return nil, err 58 - } 59 - 60 - // Single bulk query for all user quotas 61 - allQuotas, err := ui.pds.GetAllUserQuotas(ctx) 62 - if err != nil { 63 - slog.Warn("Failed to get all user quotas for crew views", "error", err) 64 - allQuotas = make(map[string]*pds.QuotaStats) 69 + http.Error(w, "Failed to list crew: "+err.Error(), http.StatusInternalServerError) 70 + return 65 71 } 66 72 67 73 defaultTier := "default" ··· 69 75 defaultTier = ui.quotaMgr.GetDefaultTier() 70 76 } 71 77 72 - var crewViews []CrewMemberView 78 + var skeletons []CrewSkeletonView 73 79 for _, member := range crew { 74 80 tier := member.Record.Tier 75 81 if tier == "" { 76 82 tier = defaultTier 77 83 } 78 - 79 - view := CrewMemberView{ 84 + skeletons = append(skeletons, CrewSkeletonView{ 80 85 RKey: member.Rkey, 81 86 DID: member.Record.Member, 82 - Handle: resolveHandle(ctx, member.Record.Member), 83 87 Role: member.Record.Role, 84 88 Permissions: member.Record.Permissions, 85 89 Tier: tier, 86 90 AddedAt: parseTime(member.Record.AddedAt), 87 - } 88 - 89 - usage := int64(0) 90 - if q, ok := allQuotas[member.Record.Member]; ok { 91 - usage = q.TotalSize 92 - } 93 - 94 - if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() { 95 - if limit := ui.quotaMgr.GetTierLimit(tier); limit != nil { 96 - view.TierLimit = formatHumanBytes(*limit) 97 - if *limit > 0 { 98 - view.UsagePercent = int(float64(usage) / float64(*limit) * 100) 99 - } 100 - } else { 101 - view.TierLimit = "Unlimited" 102 - } 103 - } else { 104 - view.TierLimit = "Unlimited" 105 - } 106 - 107 - view.CurrentUsage = usage 108 - view.UsageHuman = formatHumanBytes(view.CurrentUsage) 109 - 110 - crewViews = append(crewViews, view) 91 + }) 111 92 } 112 93 113 - sort.Slice(crewViews, func(i, j int) bool { 114 - return crewViews[i].CurrentUsage > crewViews[j].CurrentUsage 94 + sort.Slice(skeletons, func(i, j int) bool { 95 + return skeletons[i].AddedAt.After(skeletons[j].AddedAt) 115 96 }) 116 97 117 - return crewViews, nil 98 + data := struct { 99 + Crew []CrewSkeletonView 100 + }{ 101 + Crew: skeletons, 102 + } 103 + ui.renderTemplate(w, "partials/tab_crew.html", data) 118 104 } 119 105 120 - // handleCrewTab returns the crew tab content (HTMX partial) 121 - func (ui *AdminUI) handleCrewTab(w http.ResponseWriter, r *http.Request) { 122 - crewViews, err := ui.getCrewViews(r.Context()) 106 + // handleCrewMemberInfo returns a fully populated crew member row (HTMX partial). 107 + // Called per-row via hx-trigger="load" — resolves handle and fetches usage. 108 + func (ui *AdminUI) handleCrewMemberInfo(w http.ResponseWriter, r *http.Request) { 109 + ctx := r.Context() 110 + rkey := r.URL.Query().Get("rkey") 111 + if rkey == "" { 112 + http.Error(w, "Missing rkey parameter", http.StatusBadRequest) 113 + return 114 + } 115 + 116 + _, member, err := ui.pds.GetCrewMember(ctx, rkey) 123 117 if err != nil { 124 - http.Error(w, "Failed to list crew: "+err.Error(), http.StatusInternalServerError) 118 + slog.Warn("Failed to get crew member for lazy load", "rkey", rkey, "error", err) 119 + http.Error(w, "Crew member not found", http.StatusNotFound) 125 120 return 126 121 } 127 122 128 - data := struct { 129 - Crew []CrewMemberView 130 - }{ 131 - Crew: crewViews, 123 + handle := resolveHandle(ctx, member.Member) 124 + 125 + usage := int64(0) 126 + if q, err := ui.pds.GetQuotaForUser(ctx, member.Member); err == nil { 127 + usage = q.TotalSize 128 + } 129 + 130 + defaultTier := "default" 131 + if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() { 132 + defaultTier = ui.quotaMgr.GetDefaultTier() 133 + } 134 + 135 + tier := member.Tier 136 + if tier == "" { 137 + tier = defaultTier 132 138 } 133 - ui.renderTemplate(w, "partials/tab_crew.html", data) 139 + 140 + view := CrewMemberView{ 141 + RKey: rkey, 142 + DID: member.Member, 143 + Handle: handle, 144 + Role: member.Role, 145 + Permissions: member.Permissions, 146 + Tier: tier, 147 + CurrentUsage: usage, 148 + UsageHuman: formatHumanBytes(usage), 149 + AddedAt: parseTime(member.AddedAt), 150 + } 151 + 152 + if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() { 153 + if limit := ui.quotaMgr.GetTierLimit(tier); limit != nil { 154 + view.TierLimit = formatHumanBytes(*limit) 155 + if *limit > 0 { 156 + view.UsagePercent = int(float64(usage) / float64(*limit) * 100) 157 + } 158 + } else { 159 + view.TierLimit = "Unlimited" 160 + } 161 + } else { 162 + view.TierLimit = "Unlimited" 163 + } 164 + 165 + ui.renderTemplate(w, "partials/crew_member_row.html", view) 134 166 } 135 167 136 168 // handleCrewAddForm displays the add crew form
+38
pkg/hold/admin/templates/partials/crew_member_row.html
··· 1 + {{define "partials/crew_member_row.html"}} 2 + <tr id="crew-{{.RKey}}"> 3 + <td> 4 + <div> 5 + {{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}} 6 + <code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code> 7 + </div> 8 + </td> 9 + <td>{{.Role}}</td> 10 + <td> 11 + {{range .Permissions}} 12 + <span class="badge badge-ghost badge-sm mr-1 mb-1">{{.}}</span> 13 + {{end}} 14 + </td> 15 + <td> 16 + <span class="badge badge-primary badge-sm">{{.Tier}}</span> 17 + <br><small class="text-base-content/50">{{.TierLimit}}</small> 18 + </td> 19 + <td> 20 + <div class="flex flex-col gap-1 min-w-24"> 21 + <span class="text-sm">{{.UsageHuman}}</span> 22 + <progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100"></progress> 23 + <small class="text-base-content/50">{{.UsagePercent}}%</small> 24 + </div> 25 + </td> 26 + <td class="text-sm text-base-content/70">{{formatTime .AddedAt}}</td> 27 + <td> 28 + <div class="flex gap-1 justify-end"> 29 + <a href="/admin/crew/{{.RKey}}" class="btn btn-ghost btn-sm btn-square" title="Edit" aria-label="Edit crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}"> 30 + {{ icon "pencil" "size-4" }} 31 + </a> 32 + <button class="btn btn-error btn-ghost btn-sm btn-square" title="Delete" aria-label="Remove crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}" hx-post="/admin/crew/{{.RKey}}/delete" hx-confirm="Remove this crew member?" hx-target="#crew-{{.RKey}}" hx-swap="outerHTML"> 33 + {{ icon "trash-2" "size-4" }} 34 + </button> 35 + </div> 36 + </td> 37 + </tr> 38 + {{end}}
+10 -20
pkg/hold/admin/templates/partials/tab_crew.html
··· 36 36 </thead> 37 37 <tbody id="crew-list"> 38 38 {{range .Crew}} 39 - <tr id="crew-{{.RKey}}"> 39 + <tr id="crew-{{.RKey}}" 40 + hx-get="/admin/api/crew/member?rkey={{.RKey}}" 41 + hx-trigger="load" 42 + hx-swap="outerHTML"> 40 43 <td> 41 44 <div> 42 - {{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}} 43 - <code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code> 45 + <span class="loading loading-spinner loading-xs"></span> 46 + <br> 47 + <code class="text-xs text-base-content/50 break-all font-mono">{{truncate .DID 32}}</code> 44 48 </div> 45 49 </td> 46 50 <td>{{.Role}}</td> ··· 51 55 </td> 52 56 <td> 53 57 <span class="badge badge-primary badge-sm">{{.Tier}}</span> 54 - <br><small class="text-base-content/50">{{.TierLimit}}</small> 55 58 </td> 56 - <td> 57 - <div class="flex flex-col gap-1 min-w-24"> 58 - <span class="text-sm">{{.UsageHuman}}</span> 59 - <progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100"></progress> 60 - <small class="text-base-content/50">{{.UsagePercent}}%</small> 61 - </div> 59 + <td class="text-base-content/30 text-sm"> 60 + <span class="loading loading-spinner loading-xs"></span> 62 61 </td> 63 62 <td class="text-sm text-base-content/70">{{formatTime .AddedAt}}</td> 64 - <td> 65 - <div class="flex gap-1 justify-end"> 66 - <a href="/admin/crew/{{.RKey}}" class="btn btn-ghost btn-sm btn-square" title="Edit" aria-label="Edit crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}"> 67 - {{ icon "pencil" "size-4" }} 68 - </a> 69 - <button class="btn btn-error btn-ghost btn-sm btn-square" title="Delete" aria-label="Remove crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}" hx-post="/admin/crew/{{.RKey}}/delete" hx-confirm="Remove this crew member?" hx-target="#crew-{{.RKey}}" hx-swap="outerHTML"> 70 - {{ icon "trash-2" "size-4" }} 71 - </button> 72 - </div> 73 - </td> 63 + <td></td> 74 64 </tr> 75 65 {{end}} 76 66 </tbody>
+36 -21
pkg/hold/gc/gc.go
··· 741 741 return orphaned, totalBlobs, nil 742 742 } 743 743 744 + // reconcileBatchSize is the number of layer records per repo commit. 745 + // Batching reduces firehose events from N to N/batchSize. 746 + const reconcileBatchSize = 200 747 + 744 748 // reconcileMissingRecords creates layer records for manifest+layer pairs that are missing. 749 + // Records are batched into single commits to avoid flooding relays with firehose events. 745 750 func (gc *GarbageCollector) reconcileMissingRecords(ctx context.Context, missing []MissingRecordDetail, result *GCResult) { 746 - for i, m := range missing { 747 - record := atproto.NewLayerRecord( 748 - m.Digest, 749 - m.Size, 750 - m.MediaType, 751 - m.UserDID, 752 - m.ManifestURI, 753 - ) 754 - if _, _, err := gc.pds.CreateLayerRecord(ctx, record); err != nil { 755 - gc.logger.Error("Failed to create reconciled layer record", 756 - "digest", m.Digest, 757 - "manifest", m.ManifestURI, 751 + for i := 0; i < len(missing); i += reconcileBatchSize { 752 + end := i + reconcileBatchSize 753 + if end > len(missing) { 754 + end = len(missing) 755 + } 756 + chunk := missing[i:end] 757 + 758 + records := make([]*atproto.LayerRecord, 0, len(chunk)) 759 + for _, m := range chunk { 760 + records = append(records, atproto.NewLayerRecord( 761 + m.Digest, 762 + m.Size, 763 + m.MediaType, 764 + m.UserDID, 765 + m.ManifestURI, 766 + )) 767 + } 768 + 769 + created, err := gc.pds.BatchCreateLayerRecords(ctx, records) 770 + if err != nil { 771 + gc.logger.Error("Failed to create reconciled layer batch", 772 + "batchStart", i, 773 + "batchSize", len(chunk), 758 774 "error", err) 759 775 continue 760 776 } 761 - result.RecordsReconciled++ 762 - if result.RecordsReconciled%100 == 0 { 763 - gc.logger.Info("Reconciliation progress", 764 - "created", result.RecordsReconciled, 765 - "total", len(missing)) 766 - } 767 777 768 - // Throttle: ramp delay based on record index to avoid flooding relays 769 - delay := max(10*time.Millisecond, time.Duration(i)*100*time.Microsecond) 770 - time.Sleep(delay) 778 + result.RecordsReconciled += int64(created) 779 + gc.logger.Info("Reconciliation progress", 780 + "created", result.RecordsReconciled, 781 + "total", len(missing), 782 + "batch", len(chunk)) 783 + 784 + // Small delay between batches as a courtesy to relays 785 + time.Sleep(100 * time.Millisecond) 771 786 } 772 787 773 788 if result.RecordsReconciled > 0 {
+41
pkg/hold/pds/layer.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "log/slog" 6 7 7 8 "atcr.io/pkg/atproto" 8 9 "atcr.io/pkg/hold/quota" 10 + indigoatproto "github.com/bluesky-social/indigo/api/atproto" 9 11 lexutil "github.com/bluesky-social/indigo/lex/util" 10 12 "github.com/bluesky-social/indigo/repo" 11 13 ) ··· 39 41 } 40 42 41 43 return rkey, recordCID.String(), nil 44 + } 45 + 46 + // BatchCreateLayerRecords creates multiple layer records in a single repo commit. 47 + // This produces one firehose event instead of one per record. 48 + // Invalid records are skipped with a warning. Returns the number of records created. 49 + func (p *HoldPDS) BatchCreateLayerRecords(ctx context.Context, records []*atproto.LayerRecord) (int, error) { 50 + var writes []*indigoatproto.RepoApplyWrites_Input_Writes_Elem 51 + 52 + for _, record := range records { 53 + if record.Type != atproto.LayerCollection { 54 + slog.Warn("Skipping invalid record type in batch", "type", record.Type) 55 + continue 56 + } 57 + if record.Digest == "" { 58 + slog.Warn("Skipping record with empty digest in batch") 59 + continue 60 + } 61 + if record.Size <= 0 { 62 + slog.Warn("Skipping record with non-positive size in batch", "size", record.Size) 63 + continue 64 + } 65 + 66 + writes = append(writes, &indigoatproto.RepoApplyWrites_Input_Writes_Elem{ 67 + RepoApplyWrites_Create: &indigoatproto.RepoApplyWrites_Create{ 68 + Collection: atproto.LayerCollection, 69 + Value: &lexutil.LexiconTypeDecoder{Val: record}, 70 + }, 71 + }) 72 + } 73 + 74 + if len(writes) == 0 { 75 + return 0, nil 76 + } 77 + 78 + if err := p.repomgr.BatchWrite(ctx, p.uid, writes); err != nil { 79 + return 0, fmt.Errorf("batch write failed: %w", err) 80 + } 81 + 82 + return len(writes), nil 42 83 } 43 84 44 85 // GetLayerRecord retrieves a specific layer record by rkey