images.go
⎇
Raw
1package views
2
3import (
4 "strings"
5
6 "hearthforge/internal/config"
7 "hearthforge/internal/db"
8 "hearthforge/internal/util"
9
10 g "maragu.dev/gomponents"
11 . "maragu.dev/gomponents/html"
12)
13
14// ImageList renders the Images tab: one block per image path with its tags.
15// host is what a client puts in front of the image name.
16func ImageList(cfg *config.Config, user *db.SessionUser, repo *db.Repo,
17 tags []db.ImageTag, sizes map[string]int64,
18) g.Node {
19 host := cfg.PublicHost
20 isAdmin := user != nil && user.IsAdmin
21 // Group tags by image path, keeping the sorted order from the query.
22 var order []string
23 byImage := map[string][]db.ImageTag{}
24 for _, t := range tags {
25 if _, seen := byImage[t.Image]; !seen {
26 order = append(order, t.Image)
27 }
28 byImage[t.Image] = append(byImage[t.Image], t)
29 }
30 // Clients lowercase image names, so the tab shows the name they use.
31 lowerRepo := strings.ToLower(repo.Name)
32 fullName := func(image string) string { return strings.TrimSuffix(lowerRepo+"/"+image, "/") }
33
34 return Layout(Page{Title: "Images — " + repo.Name, User: user, Cfg: cfg},
35 Div(Class("container"),
36 RepoHeader(repo),
37 RepoNav(cfg, repo, "images", user),
38 Div(Class("list-header"),
39 H2(Class("list-heading"), g.Text("Images")),
40 g.If(isAdmin && len(order) > 0, Details(Class("confirm-details"),
41 Summary(Class("btn btn-sm btn-danger"), g.Text("Delete all")),
42 Div(Class("confirm-popup"),
43 g.Text("Delete every image and tag of this repository?"),
44 Form(Method("POST"), Action("/"+repo.Name+"/images/delete-all"), Class("inline-form"),
45 Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete all")),
46 ),
47 ),
48 )),
49 ),
50 g.If(len(order) == 0, Div(Class("empty-state"),
51 P(g.Text("No images yet.")),
52 g.If(isAdmin, P(Class("text-muted"),
53 g.Text("Push one with "),
54 Code(g.Text("docker push "+host+"/"+lowerRepo+":TAG")),
55 g.Text("."))),
56 )),
57 g.If(len(order) > 0, Ul(Class("issue-list"),
58 g.Map(order, func(image string) g.Node {
59 return Li(Class("issue-item"),
60 Div(Class("release-item-header"),
61 Div(Class("release-item-main"),
62 Code(Class("release-item-title"), g.Text(host+"/"+fullName(image))),
63 Div(Class("release-item-meta"),
64 Span(g.Text(countLabel(len(byImage[image]), "tag", "tags"))),
65 Span(g.Text(util.FormatBytes(sizes[image]))),
66 ),
67 ),
68 g.If(isAdmin, Details(Class("confirm-details"),
69 Summary(Class("btn btn-sm btn-danger"), g.Text("Delete image")),
70 Div(Class("confirm-popup"),
71 g.Text("Delete every tag and layer of "+fullName(image)+"?"),
72 Form(Method("POST"), Action("/"+repo.Name+"/images/delete"), Class("inline-form"),
73 Input(Type("hidden"), Name("image"), Value(image)),
74 Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")),
75 ),
76 ),
77 )),
78 ),
79 Ul(Class("image-tags"),
80 g.Map(byImage[image], func(t db.ImageTag) g.Node {
81 return Li(Class("image-tag"),
82 Span(Class("badge"), g.Text(t.Tag)),
83 // Digests passed the registry's regex, so 19 chars is "sha256:" plus 12 hex.
84 Code(Class("image-digest"), Title(t.Digest), g.Text(t.Digest[:19])),
85 g.El("time", Class("text-muted"), DateTime(t.UpdatedAt), g.Text(util.FormatDate(t.UpdatedAt))),
86 g.If(isAdmin, Form(Method("POST"), Action("/"+repo.Name+"/images/delete"),
87 Class("inline-form image-tag-delete"),
88 Input(Type("hidden"), Name("image"), Value(image)),
89 Input(Type("hidden"), Name("tag"), Value(t.Tag)),
90 Button(Type("submit"), Class("btn btn-xs"), g.Text("Delete tag")),
91 )),
92 )
93 }),
94 ),
95 )
96 }),
97 )),
98 ),
99 )
100}
101