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