Gitfed
bastien-mrq/gitfed/ Commits/ 0b25ccf

Admin: show disk usage per repo and in total

New "Storage" card on the admin dashboard (total bytes across all repos) and a new /admin/repos page listing every repo with its owner, visibility and size, sorted largest first. New gitexec.DiskUsage sums every regular file under a bare repo's directory (packfiles, refs, objects — there's no separate working tree to exclude); new RepoDiskUsage on admin.Ops wraps it per repo, threaded through adminrpc. Both the dashboard total and the repos list are best-effort per repo, so one repo with a vanished directory can't take down the whole admin page. Verified live: dashboard card and repos-page total agree (47.9 KiB across 1 repo in the local fixture), and the size grows correctly after pushing more content (new TestRepoDiskUsage). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

bastien-mrq 2026-07-31 07:50 commit 0b25ccff67f28c62186c9d0e2a366ad9852c4aa7 parent 7afef510f06dcfcbbc0dafdcf219d9ba26a82f5f
14 fichiers modifiés +293 −6
M cmd/gitfed-web/handlers_admin.go +23 −1
A cmd/gitfed-web/handlers_admin_repos.go +69 −0
M cmd/gitfed-web/render.go +23 −5
M cmd/gitfed-web/render_test.go +20 −0
M cmd/gitfed-web/routes.go +1 −0
M internal/admin/admin.go +11 −0
M internal/admin/admin_test.go +49 −0
M internal/adminrpc/client.go +6 −0
M internal/adminrpc/protocol.go +5 −0
M internal/adminrpc/server.go +8 −0
M internal/gitexec/gitexec.go +26 −0
M internal/gitexec/gitexec_test.go +30 −0
M internal/i18n/strings_en.go +11 −0
M internal/i18n/strings_fr.go +11 −0
cmd/gitfed-web/handlers_admin.go
diff --git a/cmd/gitfed-web/handlers_admin.go b/cmd/gitfed-web/handlers_admin.go index 40f223e..e6e42be 100644 --- a/cmd/gitfed-web/handlers_admin.go +++ b/cmd/gitfed-web/handlers_admin.go @@ -34,6 +34,12 @@ var adminIndexTpl = newTpl("admin-index", ` <p>{{t .Lang "admin.card_audit_sub"}}</p> <a class="go" href="/admin/audit">{{t .Lang "admin.card_audit_link"}} →</a> </div> + <div class="gf-admin-card"> + <div class="top"><span class="l">{{t .Lang "admin.card_repos"}}</span></div> + <div class="n">{{humanBytes .RepoBytes}}</div> + <p>{{t .Lang "admin.card_repos_sub" .RepoCount}}</p> + <a class="go" href="/admin/repos">{{t .Lang "admin.card_repos_link"}} →</a> + </div> </div> {{if .RecentAudit}} @@ -85,14 +91,30 @@ func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { recent = recent[:5] } + repos, err := s.ops.ListRepos() + if err != nil { + s.serverError(w, r, err) + return + } + var repoBytes int64 + for _, repo := range repos { + // Best-effort, same as the repos list page — a vanished repo + // directory shouldn't take down the admin dashboard. + if size, err := s.ops.RepoDiskUsage(repo.Name); err == nil { + repoBytes += size + } + } + lang := s.lang(r) var buf bytes.Buffer _ = adminIndexTpl.Execute(&buf, struct { UserCount, AdminCount, RegularCount int TrustCount, PendingCount int AuditCount int + RepoCount int + RepoBytes int64 Lang string RecentAudit []store.AuditEvent - }{len(users), adminCount, len(users) - adminCount, len(trust), pendingCount, len(audit), string(lang), recent}) + }{len(users), adminCount, len(users) - adminCount, len(trust), pendingCount, len(audit), len(repos), repoBytes, string(lang), recent}) s.render(w, r, i18n.T(lang, "admin.title"), "admin", template.HTML(buf.String())) }
cmd/gitfed-web/handlers_admin_repos.go
diff --git a/cmd/gitfed-web/handlers_admin_repos.go b/cmd/gitfed-web/handlers_admin_repos.go new file mode 100644 index 0000000..b0c2f39 --- /dev/null +++ b/cmd/gitfed-web/handlers_admin_repos.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + "sort" + + "git.neuromancer.ovh/bastien-mrq/gitfed/internal/i18n" + "git.neuromancer.ovh/bastien-mrq/gitfed/internal/store" +) + +// repoDiskUsageView pairs a repo with its on-disk size, sorted largest +// first so the biggest consumers surface without scanning the whole list. +type repoDiskUsageView struct { + store.Repo + Bytes int64 +} + +var adminReposTpl = newTpl("admin-repos", ` +<p><a href="/admin">&larr; {{t .Lang "nav.admin"}}</a></p> +<h1>{{t .Lang "admin.repos_title"}}</h1> +<p class="muted">{{t .Lang "admin.repos_sub" .Total (humanBytes .TotalBytes)}}</p> +<table> +<tr><th>{{t .Lang "admin.repos_col_name"}}</th><th>{{t .Lang "admin.repos_col_owner"}}</th><th>{{t .Lang "admin.repos_col_visibility"}}</th><th>{{t .Lang "admin.repos_col_size"}}</th></tr> +{{range .Repos}} +<tr> + <td><a href="/r/{{.Name}}">{{.Name}}</a></td> + <td class="muted">{{.Owner}}</td> + <td>{{if .Public}}<span class="badge trusted">{{t $.Lang "common.public"}}</span>{{else}}<span class="badge pending">{{t $.Lang "common.private"}}</span>{{end}}</td> + <td>{{humanBytes .Bytes}}</td> +</tr> +{{else}} +<tr><td colspan="4" class="muted">{{t .Lang "admin.repos_empty"}}</td></tr> +{{end}} +</table> +`) + +func (s *server) handleAdminRepos(w http.ResponseWriter, r *http.Request) { + lang := s.lang(r) + repos, err := s.ops.ListRepos() + if err != nil { + s.serverError(w, r, err) + return + } + + views := make([]repoDiskUsageView, 0, len(repos)) + var total int64 + for _, repo := range repos { + // Best-effort per repo — a repo whose directory vanished out from + // under the store shouldn't take down the whole admin page. + size, err := s.ops.RepoDiskUsage(repo.Name) + if err != nil { + continue + } + views = append(views, repoDiskUsageView{Repo: repo, Bytes: size}) + total += size + } + sort.Slice(views, func(i, j int) bool { return views[i].Bytes > views[j].Bytes }) + + var buf bytes.Buffer + _ = adminReposTpl.Execute(&buf, struct { + Repos []repoDiskUsageView + Total int + TotalBytes int64 + Lang string + }{views, len(views), total, string(lang)}) + s.render(w, r, i18n.T(lang, "admin.repos_title"), "admin", template.HTML(buf.String())) +}
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 526e94f..9e4c960 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "fmt" "html/template" "log" "net/http" @@ -23,11 +24,12 @@ import ( // newTpl rather than per-request, since the language is passed as an // explicit argument instead of captured in a closure. var commonFuncs = template.FuncMap{ - "t": func(lang, key string, args ...any) string { return i18n.T(i18n.Lang(lang), key, args...) }, - "icon": icon, - "fileIcon": fileIcon, - "roleLabel": func(lang, role string) string { return roleLabel(i18n.Lang(lang), role) }, - "localUser": localUser, + "t": func(lang, key string, args ...any) string { return i18n.T(i18n.Lang(lang), key, args...) }, + "icon": icon, + "fileIcon": fileIcon, + "roleLabel": func(lang, role string) string { return roleLabel(i18n.Lang(lang), role) }, + "localUser": localUser, + "humanBytes": humanBytes, } // localUser returns the bare username if principal ("user@domain") belongs @@ -174,6 +176,22 @@ func icon(name string) template.HTML { return template.HTML(`<svg class="icon" aria-hidden="true"><use href="#ic-` + name + `"/></svg>`) } +// humanBytes formats a byte count the way a file manager would (1 decimal +// place past KiB, binary/1024-based units) — for the admin repos page's +// disk usage column. +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + var ( codeExtensions = map[string]bool{ ".go": true, ".js": true, ".mjs": true, ".ts": true, ".jsx": true, ".tsx": true,
cmd/gitfed-web/render_test.go
diff --git a/cmd/gitfed-web/render_test.go b/cmd/gitfed-web/render_test.go index 446e7c6..705f002 100644 --- a/cmd/gitfed-web/render_test.go +++ b/cmd/gitfed-web/render_test.go @@ -28,3 +28,23 @@ func TestFileIcon(t *testing.T) { } } } + +func TestHumanBytes(t *testing.T) { + cases := []struct { + n int64 + want string + }{ + {0, "0 B"}, + {1, "1 B"}, + {1023, "1023 B"}, + {1024, "1.0 KiB"}, + {1536, "1.5 KiB"}, + {1024 * 1024, "1.0 MiB"}, + {1024*1024*1024*3 + 1024*1024*512, "3.5 GiB"}, + } + for _, c := range cases { + if got := humanBytes(c.n); got != c.want { + t.Errorf("humanBytes(%d) = %q, want %q", c.n, got, c.want) + } + } +}
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 13e8b85..5b35388 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -68,6 +68,7 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /admin/users", s.requireAdmin(s.handleAdminUsersCreate)) mux.HandleFunc("POST /admin/users/delete", s.requireAdmin(s.handleAdminUsersDelete)) mux.HandleFunc("POST /admin/users/set-admin", s.requireAdmin(s.handleAdminUsersSetAdmin)) + mux.HandleFunc("GET /admin/repos", s.requireAdmin(s.handleAdminRepos)) mux.HandleFunc("GET /admin/trust", s.requireAdmin(s.handleAdminTrustList)) mux.HandleFunc("POST /admin/trust/approve", s.requireAdmin(s.handleAdminTrustApprove)) mux.HandleFunc("GET /admin/audit", s.requireAdmin(s.handleAdminAudit))
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index c72cfef..c2ea5e0 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -71,6 +71,7 @@ type Ops interface { CountCommits(name string) (count int, found bool, err error) CountContributors(name string) (count int, found bool, err error) DominantLanguage(name string) (language string, found bool, err error) + RepoDiskUsage(name string) (bytes int64, err error) ShowCommit(name, hash string) (detail gitexec.CommitDetail, found bool, err error) CommitDiff(name, hash string) (diff string, truncated bool, err error) ListBranches(name string) (branches []string, err error) @@ -562,6 +563,16 @@ func (a *Admin) DominantLanguage(name string) (string, bool, error) { return gitexec.DominantLanguage(repo.Path) } +// RepoDiskUsage returns name's total on-disk size in bytes, for the admin +// repos list — see gitexec.DiskUsage for what's counted. +func (a *Admin) RepoDiskUsage(name string) (int64, error) { + repo, err := a.Store.GetRepo(name) + if err != nil { + return 0, err + } + return gitexec.DiskUsage(repo.Path) +} + func (a *Admin) ShowCommit(name, hash string) (gitexec.CommitDetail, bool, error) { repo, err := a.Store.GetRepo(name) if err != nil {
internal/admin/admin_test.go
diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go index b32fb8d..0aeea0f 100644 --- a/internal/admin/admin_test.go +++ b/internal/admin/admin_test.go @@ -236,6 +236,55 @@ func runGit(t *testing.T, dir string, args ...string) { } } +// TestRepoDiskUsage guards the admin repos page's size column: a repo's +// reported usage must actually grow once real content is pushed to it, and +// an unknown repo name must error rather than silently reporting 0. +func TestRepoDiskUsage(t *testing.T) { + s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer s.Close() + a := New(s, nil, "local.test", t.TempDir(), testCA(t)) + + if err := a.CreateRepo("alice/repo", "alice"); err != nil { + t.Fatalf("create repo: %v", err) + } + before, err := a.RepoDiskUsage("alice/repo") + if err != nil { + t.Fatalf("RepoDiskUsage (empty repo): %v", err) + } + + repo, err := s.GetRepo("alice/repo") + if err != nil { + t.Fatalf("get repo: %v", err) + } + work := filepath.Join(t.TempDir(), "work") + if err := os.Mkdir(work, 0755); err != nil { + t.Fatal(err) + } + runGit(t, work, "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(work, "big.bin"), make([]byte, 100_000), 0644); err != nil { + t.Fatal(err) + } + runGit(t, work, "add", ".") + runGit(t, work, "commit", "-q", "-m", "seed") + runGit(t, work, "remote", "add", "origin", repo.Path) + runGit(t, work, "push", "-q", "origin", "main") + + after, err := a.RepoDiskUsage("alice/repo") + if err != nil { + t.Fatalf("RepoDiskUsage (after push): %v", err) + } + if after <= before { + t.Fatalf("disk usage after pushing a 100KB file = %d, want > %d (before)", after, before) + } + + if _, err := a.RepoDiskUsage("does-not-exist"); err == nil { + t.Fatal("RepoDiskUsage on an unknown repo: want an error, got nil") + } +} + func TestReadmeLanguageVariants(t *testing.T) { s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db")) if err != nil {
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index 581a062..a193d82 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -271,6 +271,12 @@ func (c *Client) DominantLanguage(name string) (string, bool, error) { return out.Language, out.Found, err } +func (c *Client) RepoDiskUsage(name string) (int64, error) { + var out diskUsageResult + err := c.call(methodRepoDiskUsage, nameArgs{Name: name}, &out) + return out.Bytes, err +} + func (c *Client) GetUser(username string) (store.User, error) { var out userResult err := c.call(methodGetUser, nameArgs{Name: username}, &out)
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index 896104b..dabb5c0 100644 --- a/internal/adminrpc/protocol.go +++ b/internal/adminrpc/protocol.go @@ -59,6 +59,7 @@ const ( methodCountCommits = "CountCommits" methodCountContributors = "CountContributors" methodDominantLanguage = "DominantLanguage" + methodRepoDiskUsage = "RepoDiskUsage" methodShowCommit = "ShowCommit" methodCommitDiff = "CommitDiff" methodListBranches = "ListBranches" @@ -234,6 +235,10 @@ type languageResult struct { Found bool `json:"found"` } +type diskUsageResult struct { + Bytes int64 `json:"bytes"` +} + type userResult struct { User store.User `json:"user"` }
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index a9badbe..414be72 100644 --- a/internal/adminrpc/server.go +++ b/internal/adminrpc/server.go @@ -307,6 +307,14 @@ func (s *Server) dispatch(req wireRequest) (any, error) { lang, found, err := s.ops.DominantLanguage(a.Name) return languageResult{Language: lang, Found: found}, err + case methodRepoDiskUsage: + var a nameArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + bytes, err := s.ops.RepoDiskUsage(a.Name) + return diskUsageResult{Bytes: bytes}, err + case methodGetUser: var a nameArgs if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index d811a77..3690674 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -8,6 +8,7 @@ import ( "context" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -503,6 +504,31 @@ func DominantLanguage(repoPath string) (language string, found bool, err error) return best, best != "", nil } +// DiskUsage returns the total size in bytes of every regular file under +// repoPath. Repos are always bare (see InitBareRepo) — the whole directory +// *is* the repo's storage (packfiles, refs, objects), there's no separate +// working tree to exclude. +func DiskUsage(repoPath string) (int64, error) { + var total int64 + err := filepath.WalkDir(repoPath, func(_ string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type().IsRegular() { + info, err := d.Info() + if err != nil { + return err + } + total += info.Size() + } + return nil + }) + if err != nil { + return 0, fmt.Errorf("gitexec: disk usage for %s: %w", repoPath, err) + } + return total, nil +} + // ListCommits returns up to limit commits reachable from the repo's default // branch (see resolveDefaultRef), most recent first. found is false (nil // error) for a repo with no commits yet.
internal/gitexec/gitexec_test.go
diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index a2fb6d9..3c9b7c1 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -545,6 +545,36 @@ func TestDominantLanguageNoRecognizedFiles(t *testing.T) { } } +func TestDiskUsage(t *testing.T) { + tmp := t.TempDir() + if err := os.WriteFile(filepath.Join(tmp, "a"), make([]byte, 100), 0644); err != nil { + t.Fatal(err) + } + sub := filepath.Join(tmp, "objects", "pack") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "b"), make([]byte, 250), 0644); err != nil { + t.Fatal(err) + } + // A subdirectory itself has some size on most filesystems, but + // DiskUsage must count regular files only — otherwise the total would + // vary by filesystem/OS instead of being the exact byte count below. + got, err := DiskUsage(tmp) + if err != nil { + t.Fatalf("DiskUsage: %v", err) + } + if got != 350 { + t.Fatalf("DiskUsage = %d, want 350 (100 + 250 across a nested dir)", got) + } +} + +func TestDiskUsageMissingPath(t *testing.T) { + if _, err := DiskUsage(filepath.Join(t.TempDir(), "does-not-exist")); err == nil { + t.Fatal("DiskUsage on a missing path: want an error, got nil") + } +} + func TestListCommitsPage(t *testing.T) { tmp := t.TempDir() barePath := filepath.Join(tmp, "repo.git")
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index 906967f..ca437cf 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -240,6 +240,9 @@ var en = map[string]string{ "admin.card_audit": "Audit log", "admin.card_audit_sub": "most recent events on record", "admin.card_audit_link": "View audit log", + "admin.card_repos": "Storage", + "admin.card_repos_sub": "across %d repos", + "admin.card_repos_link": "View repos", "admin.recent_activity": "Recent activity", "admin.audit_allow": "allow", "admin.audit_deny": "deny", @@ -279,6 +282,14 @@ var en = map[string]string{ "admin.audit_empty": "No events recorded yet.", "admin.audit_note": "Showing the most recent 200 events.", + "admin.repos_title": "Admin — Repos", + "admin.repos_sub": "%d repos, %s total on disk", + "admin.repos_col_name": "Name", + "admin.repos_col_owner": "Owner", + "admin.repos_col_visibility": "Visibility", + "admin.repos_col_size": "Size", + "admin.repos_empty": "No repos yet.", + // ---------- repo ---------- "repo.owner": "owner", "repo.copy_clone_url": "Copy clone URL",
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index f835114..3a60574 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -240,6 +240,9 @@ var fr = map[string]string{ "admin.card_audit": "Journal d'audit", "admin.card_audit_sub": "événements récents enregistrés", "admin.card_audit_link": "Voir le journal d'audit", + "admin.card_repos": "Stockage", + "admin.card_repos_sub": "sur %d dépôts", + "admin.card_repos_link": "Voir les dépôts", "admin.recent_activity": "Activité récente", "admin.audit_allow": "autorisé", "admin.audit_deny": "refusé", @@ -279,6 +282,14 @@ var fr = map[string]string{ "admin.audit_empty": "Aucun événement enregistré pour le moment.", "admin.audit_note": "Affichage des 200 événements les plus récents.", + "admin.repos_title": "Administration — Dépôts", + "admin.repos_sub": "%d dépôts, %s au total sur disque", + "admin.repos_col_name": "Nom", + "admin.repos_col_owner": "Propriétaire", + "admin.repos_col_visibility": "Visibilité", + "admin.repos_col_size": "Taille", + "admin.repos_empty": "Aucun dépôt pour le moment.", + // ---------- repo ---------- "repo.owner": "propriétaire", "repo.copy_clone_url": "Copier l'URL de clonage",