From ac3132d17295671118b29141f4452b6209ce2438 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 5 Apr 2026 13:56:55 +0300 Subject: [PATCH] feat: show local Docker images on project detail page - Add GET /api/projects/{id}/images endpoint returning local images matching the project - Add ListImagesByRef with tag, size, and created timestamp to Docker client - Display images table on project page with tag, ID (truncated), size (MB), and created date - Only shown when Docker is available and images exist locally --- internal/api/docker.go | 36 +++++++++++++++++++++ internal/api/router.go | 1 + internal/docker/image.go | 20 ++++++++---- web/src/lib/api.ts | 5 +++ web/src/lib/i18n/en.json | 5 +++ web/src/lib/i18n/ru.json | 5 +++ web/src/lib/types.ts | 9 ++++++ web/src/routes/projects/[id]/+page.svelte | 38 ++++++++++++++++++++++- 8 files changed, 112 insertions(+), 7 deletions(-) diff --git a/internal/api/docker.go b/internal/api/docker.go index 4f5fcda..ca1a442 100644 --- a/internal/api/docker.go +++ b/internal/api/docker.go @@ -1,10 +1,46 @@ package api import ( + "errors" "log/slog" "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/store" ) +// listProjectImages handles GET /api/projects/{id}/images. +// Returns all local Docker images matching the project's image reference. +func (s *Server) listProjectImages(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + project, err := s.store.GetProjectByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + slog.Error("failed to get project", "error", err) + respondError(w, http.StatusInternalServerError, "internal server error") + return + } + + if s.docker == nil || project.Image == "" { + respondJSON(w, http.StatusOK, []any{}) + return + } + + images, err := s.docker.ListImagesByRef(r.Context(), project.Image) + if err != nil { + slog.Warn("list project images", "project", project.Name, "error", err) + respondJSON(w, http.StatusOK, []any{}) + return + } + + respondJSON(w, http.StatusOK, images) +} + // pruneImages handles POST /api/docker/prune-images. // Only removes images that belong to Docker Watcher projects (not all system images). func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/router.go b/internal/api/router.go index 698cc0d..fc09e91 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -204,6 +204,7 @@ func (s *Server) Router() chi.Router { r.Get("/stages/{stage}/env", s.listStageEnv) r.Get("/stages/{stage}/instances", s.listInstances) r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats) + r.Get("/images", s.listProjectImages) r.Get("/volumes", s.listVolumes) r.Get("/volumes/{volId}/browse", s.browseVolume) r.Get("/volumes/{volId}/download", s.downloadVolume) diff --git a/internal/docker/image.go b/internal/docker/image.go index bb5ece3..9aca4bf 100644 --- a/internal/docker/image.go +++ b/internal/docker/image.go @@ -115,9 +115,11 @@ func EncodeRegistryAuth(username, password, serverAddress string) (string, error // LocalImage represents a Docker image on the local machine. type LocalImage struct { - ID string `json:"id"` - Ref string `json:"ref"` // e.g., "registry/org/app:tag" - Size int64 `json:"size"` // bytes + ID string `json:"id"` + Ref string `json:"ref"` // e.g., "registry/org/app:tag" + Tag string `json:"tag"` // just the tag part + Size int64 `json:"size"` // bytes + Created int64 `json:"created"` // unix timestamp } // ListImagesByRef returns all local images matching a given image reference prefix. @@ -132,10 +134,16 @@ func (c *Client) ListImagesByRef(ctx context.Context, imageBase string) ([]Local for _, img := range result.Items { for _, tag := range img.RepoTags { if strings.HasPrefix(tag, imageBase+":") || tag == imageBase { + tagPart := "" + if idx := strings.LastIndex(tag, ":"); idx != -1 { + tagPart = tag[idx+1:] + } images = append(images, LocalImage{ - ID: img.ID, - Ref: tag, - Size: img.Size, + ID: img.ID, + Ref: tag, + Tag: tagPart, + Size: img.Size, + Created: img.Created, }) } } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c86fcb0..1e0146e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -9,6 +9,7 @@ import type { EventLogStats, InspectResult, Instance, + LocalImage, NpmCertificate, NpmAccessList, ProxyRoute, @@ -278,6 +279,10 @@ export function listProxyRoutes(): Promise { // ── Docker Management ────────────────────────────────────────────── +export function listProjectImages(projectId: string): Promise { + return get(`/api/projects/${projectId}/images`); +} + export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> { return post<{ images_removed: number; space_reclaimed_mb: number }>('/api/docker/prune-images'); } diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index d19cf54..b3b4ed0 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -105,6 +105,11 @@ "enableProxy": "Enable Proxy", "accessListId": "NPM Access List ID", "accessListIdHelp": "Per-project override. 0 = use global default from NPM settings.", + "localImages": "Local Docker Images", + "imageTag": "Tag", + "imageId": "Image ID", + "imageSize": "Size", + "imageCreated": "Created", "cpuLimit": "CPU Limit (cores)", "cpuLimitHelp": "e.g., 0.5, 1, 2. Leave 0 for unlimited", "memoryLimit": "Memory Limit (MB)", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 41876ef..e58a738 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -105,6 +105,11 @@ "enableProxy": "Включить прокси", "accessListId": "ID списка доступа NPM", "accessListIdHelp": "Переопределение для проекта. 0 = использовать глобальное из настроек NPM.", + "localImages": "Локальные Docker-образы", + "imageTag": "Тег", + "imageId": "ID образа", + "imageSize": "Размер", + "imageCreated": "Создан", "cpuLimit": "Лимит CPU (ядра)", "cpuLimitHelp": "напр., 0.5, 1, 2. Оставьте 0 для без ограничений", "memoryLimit": "Лимит памяти (МБ)", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 89b967e..d2cbe3d 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -262,6 +262,15 @@ export interface ProxyHealth { error?: string; } +/** A local Docker image. */ +export interface LocalImage { + id: string; + ref: string; + tag: string; + size: number; + created: number; +} + /** An NPM access list for proxy authentication. */ export interface NpmAccessList { id: number; diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index 00497bc..6a44572 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -1,7 +1,7 @@