From 787de74b3d420ea3a89cc6ec57c874fd8860a1e9 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Fri, 3 Jul 2026 14:46:41 +1000 Subject: [PATCH] fix: show local-repo files in the cached-objects UI (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Local repos store uploaded files in the \`local_files\` table, whereas remote/proxy repos cache into the \`artifacts\` table. The shared **Cached Objects** page always queried the artifacts table via \`/api/v2/remotes/{name}/objects\`, so files uploaded to a local repo (e.g. an internal RPM) were fully stored and servable but showed as **0 objects** in the UI. ## Changes - Add \`ListLocalArtifacts\`, joining \`local_files\` with \`blobs\` and returning \`models.Artifact\`-shaped rows (size from the blob; access/fetch counters zero and timestamps derived from \`created_at\`, since local files track no access). - Add \`LocalRoutes\` to the objects handler: \`listLocal\` reads \`local_files\`, \`evictLocal\` deletes via \`DeleteLocalFile\`. Extract shared page/per_page parsing into \`pageBounds\`. - Mount \`/api/v2/locals/{name}/objects\` (GET + DELETE) in the server. - Add \`listLocalObjects\`/\`evictLocalObject\` to the UI client and route the Objects page to them when viewing a local repo. - Cover the listing and eviction paths with a dockerised test. ## Notes Generated \`repodata/*\` files are not listed — they are produced on the fly from \`rpm_metadata\` and never stored in \`local_files\`, which matches how the repo serves them. Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/99 Co-authored-by: Ben Vincent Co-committed-by: Ben Vincent --- internal/api/v2/local_objects_test.go | 78 +++++++++++++++++++++++++++ internal/api/v2/objects.go | 45 ++++++++++++++-- internal/database/local_files.go | 35 ++++++++++++ internal/server/server.go | 6 +++ ui/src/api/client.ts | 6 +++ ui/src/pages/Objects.tsx | 7 +-- 6 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 internal/api/v2/local_objects_test.go diff --git a/internal/api/v2/local_objects_test.go b/internal/api/v2/local_objects_test.go new file mode 100644 index 0000000..9f0d029 --- /dev/null +++ b/internal/api/v2/local_objects_test.go @@ -0,0 +1,78 @@ +package v2 + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +// TestLocalObjectsListing verifies that files uploaded to a local repo (which +// live in local_files, not artifacts) are listed by the local objects endpoint +// and can be evicted through it. +func TestLocalObjectsListing(t *testing.T) { + if testDSN == "" { + t.Skip("Docker unavailable") + } + ctx := context.Background() + db, err := database.New(testDSN) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + const repo = "rpm-local-objs" + if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageRPM, RepoType: models.RepoTypeLocal}); err != nil { + t.Fatal(err) + } + + const hash = "sha256:aa11" + const path = "Packages/example-0.1.0-1.x86_64.rpm" + if err := db.UpsertBlob(ctx, hash, "blobs/aa/11", 1234, "application/x-rpm"); err != nil { + t.Fatal(err) + } + if err := db.CreateLocalFile(ctx, repo, path, hash); err != nil { + t.Fatal(err) + } + + h := NewObjectsHandler(db) + router := chi.NewRouter() + router.Route("/locals/{name}/objects", func(r chi.Router) { + r.Get("/", h.LocalRoutes().ServeHTTP) + r.Delete("/*", h.LocalRoutes().ServeHTTP) + }) + + // The uploaded package must appear in the listing with its blob size. + req := httptest.NewRequest("GET", "/locals/"+repo+"/objects", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("list = %d, want 200", w.Code) + } + var got []models.Artifact + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d objects, want 1", len(got)) + } + if got[0].Path != path || got[0].SizeBytes != 1234 || got[0].ContentHash != hash { + t.Fatalf("unexpected object: %+v", got[0]) + } + + // Eviction removes it from local_files. + del := httptest.NewRequest("DELETE", "/locals/"+repo+"/objects/"+path, nil) + dw := httptest.NewRecorder() + router.ServeHTTP(dw, del) + if dw.Code != 204 { + t.Fatalf("evict = %d, want 204", dw.Code) + } + if f, _ := db.GetLocalFile(ctx, repo, path); f != nil { + t.Fatalf("file still present after evict: %+v", f) + } +} diff --git a/internal/api/v2/objects.go b/internal/api/v2/objects.go index 962e2ef..4851409 100644 --- a/internal/api/v2/objects.go +++ b/internal/api/v2/objects.go @@ -25,9 +25,18 @@ func (h *ObjectsHandler) Routes() chi.Router { return r } -func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) { - remoteName := chi.URLParam(r, "name") - limit, _ := strconv.Atoi(r.URL.Query().Get("per_page")) +// LocalRoutes lists and evicts objects for local repos, which live in the +// local_files table rather than the artifacts table used by remotes. +func (h *ObjectsHandler) LocalRoutes() chi.Router { + r := chi.NewRouter() + r.Get("/", h.listLocal) + r.Delete("/*", h.evictLocal) + return r +} + +// pageBounds parses the shared page/per_page query params into a SQL limit and offset. +func pageBounds(r *http.Request) (limit, offset int) { + limit, _ = strconv.Atoi(r.URL.Query().Get("per_page")) if limit <= 0 || limit > 5000 { limit = 50 } @@ -35,7 +44,12 @@ func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) { if page <= 0 { page = 1 } - offset := (page - 1) * limit + return limit, (page - 1) * limit +} + +func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) { + remoteName := chi.URLParam(r, "name") + limit, offset := pageBounds(r) artifacts, err := h.db.ListArtifacts(r.Context(), remoteName, limit, offset) if err != nil { @@ -45,6 +59,29 @@ func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, artifacts) } +func (h *ObjectsHandler) listLocal(w http.ResponseWriter, r *http.Request) { + repoName := chi.URLParam(r, "name") + limit, offset := pageBounds(r) + + artifacts, err := h.db.ListLocalArtifacts(r.Context(), repoName, limit, offset) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, artifacts) +} + +func (h *ObjectsHandler) evictLocal(w http.ResponseWriter, r *http.Request) { + repoName := chi.URLParam(r, "name") + path := chi.URLParam(r, "*") + + if err := h.db.DeleteLocalFile(r.Context(), repoName, path); err != nil { + http.Error(w, fmt.Sprintf("evict failed: %v", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (h *ObjectsHandler) evict(w http.ResponseWriter, r *http.Request) { remoteName := chi.URLParam(r, "name") path := chi.URLParam(r, "*") diff --git a/internal/database/local_files.go b/internal/database/local_files.go index dd00f71..9f93bdd 100644 --- a/internal/database/local_files.go +++ b/internal/database/local_files.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "git.unkin.net/unkin/artifactapi/internal/provider" + "git.unkin.net/unkin/artifactapi/pkg/models" ) type LocalFile struct { @@ -78,6 +79,40 @@ func (db *DB) ListLocalFiles(ctx context.Context, repoName string, limit, offset return files, rows.Err() } +// ListLocalArtifacts returns a repo's local files shaped as models.Artifact so +// the UI's cached-objects view can render them the same way as remote artifacts. +// Local files carry no access/fetch counters, so those are left at zero and the +// timestamps are all derived from created_at. +func (db *DB) ListLocalArtifacts(ctx context.Context, repoName string, limit, offset int) ([]models.Artifact, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT lf.id, lf.repo_name, lf.file_path, lf.content_hash, + lf.created_at, b.size_bytes, b.content_type + FROM local_files lf + JOIN blobs b ON lf.content_hash = b.content_hash + WHERE lf.repo_name = $1 + ORDER BY lf.file_path + LIMIT $2 OFFSET $3 + `, repoName, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var artifacts []models.Artifact + for rows.Next() { + var a models.Artifact + var createdAt time.Time + if err := rows.Scan(&a.ID, &a.RemoteName, &a.Path, &a.ContentHash, &createdAt, &a.SizeBytes, &a.ContentType); err != nil { + return nil, err + } + a.FirstSeenAt = createdAt + a.LastFetchedAt = createdAt + a.LastAccessedAt = createdAt + artifacts = append(artifacts, a) + } + return artifacts, rows.Err() +} + func (db *DB) ListLocalFilesByPrefix(ctx context.Context, repoName, prefix string) ([]LocalFile, error) { rows, err := db.Pool.Query(ctx, ` SELECT id, repo_name, file_path, content_hash, created_at diff --git a/internal/server/server.go b/internal/server/server.go index 1a93ebd..6531606 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -121,6 +121,12 @@ func (s *Server) routes() chi.Router { r.Delete("/*", objHandler.Routes().ServeHTTP) }) + r.Route("/locals/{name}/objects", func(r chi.Router) { + objHandler := v2.NewObjectsHandler(s.db) + r.Get("/", objHandler.LocalRoutes().ServeHTTP) + r.Delete("/*", objHandler.LocalRoutes().ServeHTTP) + }) + r.Route("/remotes/{name}/files", func(r chi.Router) { r.Put("/*", s.localHandler.Routes().ServeHTTP) r.Get("/*", s.localHandler.Routes().ServeHTTP) diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index c5ffa7b..f5d942d 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -34,6 +34,12 @@ export const api = { evictObject: (remote: string, path: string) => fetchJSON(`/api/v2/remotes/${remote}/objects/${path}`, { method: 'DELETE' }), + listLocalObjects: (name: string, page = 1, perPage = 50) => + fetchJSON(`/api/v2/locals/${name}/objects?page=${page}&per_page=${perPage}`), + + evictLocalObject: (name: string, path: string) => + fetchJSON(`/api/v2/locals/${name}/objects/${path}`, { method: 'DELETE' }), + flushRemoteCache: (remote: string) => fetchJSON(`/api/v2/remotes/${remote}/cache`, { method: 'DELETE' }), diff --git a/ui/src/pages/Objects.tsx b/ui/src/pages/Objects.tsx index f664f8b..816e2b1 100644 --- a/ui/src/pages/Objects.tsx +++ b/ui/src/pages/Objects.tsx @@ -182,16 +182,17 @@ export function Objects() { const load = useCallback(() => { if (!name) return; setLoading(true); - api.listObjects(name, 1, 5000) + const req = isLocal ? api.listLocalObjects(name, 1, 5000) : api.listObjects(name, 1, 5000); + req .then(a => setArtifacts(a || [])) .finally(() => setLoading(false)); - }, [name]); + }, [name, isLocal]); useEffect(() => { load(); }, [load]); const handleEvict = async (path: string) => { if (!name || !confirm(`Evict ${path}?`)) return; - await api.evictObject(name, path); + await (isLocal ? api.evictLocalObject(name, path) : api.evictObject(name, path)); load(); };