images.go RawHistoryFolder

1package admin
2
3import (
4	"encoding/json"
5	"errors"
6	"io"
7	"net/http"
8
9	"github.com/bnema/zerowrap"
10
11	"github.com/bnema/gordon/internal/adapters/dto"
12	"github.com/bnema/gordon/internal/domain"
13)
14
15func toImageResponse(image domain.ImageInfo) dto.Image {
16	return dto.Image{
17		Repository: image.Repository,
18		Tag:        image.Tag,
19		Size:       image.Size,
20		Created:    image.Created,
21		ID:         image.ID,
22		Dangling:   image.Dangling,
23	}
24}
25
26func toImagePruneResponse(report domain.ImagePruneReport) dto.ImagePruneResponse {
27	return dto.ImagePruneResponse{
28		Runtime: dto.RuntimePruneResult{
29			DeletedCount:   report.Runtime.DeletedCount,
30			SpaceReclaimed: report.Runtime.SpaceReclaimed,
31		},
32		Registry: dto.RegistryPruneResult{
33			TagsRemoved:    report.Registry.TagsRemoved,
34			BlobsRemoved:   report.Registry.BlobsRemoved,
35			SpaceReclaimed: report.Registry.SpaceReclaimed,
36		},
37		Plan: toPruneSummary(report.Plan),
38	}
39}
40
41func (h *Handler) handleImages(w http.ResponseWriter, r *http.Request, path string) {
42	if path != "/images" && path != "/images/prune" {
43		h.sendError(w, http.StatusNotFound, "route not found")
44		return
45	}
46
47	if h.imageSvc == nil {
48		h.sendError(w, http.StatusServiceUnavailable, "image service not available")
49		return
50	}
51
52	switch {
53	case path == "/images" && r.Method == http.MethodGet:
54		h.handleImagesGet(w, r)
55	case path == "/images/prune" && r.Method == http.MethodPost:
56		h.handleImagesPrune(w, r)
57	default:
58		h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
59	}
60}
61
62func (h *Handler) handleImagesGet(w http.ResponseWriter, r *http.Request) {
63	ctx := r.Context()
64
65	if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
66		h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
67		return
68	}
69
70	images, err := h.imageSvc.ListImages(ctx)
71	if err != nil {
72		h.sendError(w, http.StatusInternalServerError, "failed to list images")
73		return
74	}
75
76	response := make([]dto.Image, 0, len(images))
77	for _, image := range images {
78		response = append(response, toImageResponse(image))
79	}
80
81	h.sendJSON(w, http.StatusOK, dto.ImagesResponse{Images: response})
82}
83
84func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) {
85	ctx := r.Context()
86	log := zerowrap.FromCtx(ctx)
87
88	if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
89		h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
90		return
91	}
92
93	r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
94	var req dto.ImagePruneRequest
95	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
96		log.Warn().Err(err).Msg("invalid image prune JSON")
97		h.sendError(w, http.StatusBadRequest, "invalid JSON")
98		return
99	}
100
101	opts := domain.DefaultImagePruneOptions()
102	if req.KeepLast != nil {
103		if *req.KeepLast < 0 {
104			h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")
105			return
106		}
107		opts.KeepLast = *req.KeepLast
108	}
109	if req.PruneDangling != nil {
110		opts.PruneDangling = *req.PruneDangling
111	}
112	if req.PruneRegistry != nil {
113		opts.PruneRegistry = *req.PruneRegistry
114	}
115	if req.DryRun != nil {
116		opts.DryRun = *req.DryRun
117	}
118	if !opts.PruneDangling && !opts.PruneRegistry {
119		h.sendError(w, http.StatusBadRequest, "at least one prune scope must be enabled")
120		return
121	}
122
123	report, err := h.imageSvc.Prune(ctx, opts)
124	if err != nil {
125		log.Error().Err(err).Int("keep_last", opts.KeepLast).Msg("image prune failed")
126		if errors.Is(err, domain.ErrPruneDisabled) {
127			h.sendJSON(w, http.StatusConflict, dto.AppError{Error: "prune-disabled", Message: err.Error()})
128			return
129		}
130		h.sendError(w, http.StatusInternalServerError, "failed to prune images")
131		return
132	}
133
134	h.sendJSON(w, http.StatusOK, toImagePruneResponse(report))
135}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close