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">← {{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",