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
This commit is contained in:
@@ -1,10 +1,46 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"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.
|
// pruneImages handles POST /api/docker/prune-images.
|
||||||
// Only removes images that belong to Docker Watcher projects (not all system images).
|
// Only removes images that belong to Docker Watcher projects (not all system images).
|
||||||
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Get("/stages/{stage}/env", s.listStageEnv)
|
r.Get("/stages/{stage}/env", s.listStageEnv)
|
||||||
r.Get("/stages/{stage}/instances", s.listInstances)
|
r.Get("/stages/{stage}/instances", s.listInstances)
|
||||||
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
|
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
|
||||||
|
r.Get("/images", s.listProjectImages)
|
||||||
r.Get("/volumes", s.listVolumes)
|
r.Get("/volumes", s.listVolumes)
|
||||||
r.Get("/volumes/{volId}/browse", s.browseVolume)
|
r.Get("/volumes/{volId}/browse", s.browseVolume)
|
||||||
r.Get("/volumes/{volId}/download", s.downloadVolume)
|
r.Get("/volumes/{volId}/download", s.downloadVolume)
|
||||||
|
|||||||
@@ -115,9 +115,11 @@ func EncodeRegistryAuth(username, password, serverAddress string) (string, error
|
|||||||
|
|
||||||
// LocalImage represents a Docker image on the local machine.
|
// LocalImage represents a Docker image on the local machine.
|
||||||
type LocalImage struct {
|
type LocalImage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Ref string `json:"ref"` // e.g., "registry/org/app:tag"
|
Ref string `json:"ref"` // e.g., "registry/org/app:tag"
|
||||||
Size int64 `json:"size"` // bytes
|
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.
|
// 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 _, img := range result.Items {
|
||||||
for _, tag := range img.RepoTags {
|
for _, tag := range img.RepoTags {
|
||||||
if strings.HasPrefix(tag, imageBase+":") || tag == imageBase {
|
if strings.HasPrefix(tag, imageBase+":") || tag == imageBase {
|
||||||
|
tagPart := ""
|
||||||
|
if idx := strings.LastIndex(tag, ":"); idx != -1 {
|
||||||
|
tagPart = tag[idx+1:]
|
||||||
|
}
|
||||||
images = append(images, LocalImage{
|
images = append(images, LocalImage{
|
||||||
ID: img.ID,
|
ID: img.ID,
|
||||||
Ref: tag,
|
Ref: tag,
|
||||||
Size: img.Size,
|
Tag: tagPart,
|
||||||
|
Size: img.Size,
|
||||||
|
Created: img.Created,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
EventLogStats,
|
EventLogStats,
|
||||||
InspectResult,
|
InspectResult,
|
||||||
Instance,
|
Instance,
|
||||||
|
LocalImage,
|
||||||
NpmCertificate,
|
NpmCertificate,
|
||||||
NpmAccessList,
|
NpmAccessList,
|
||||||
ProxyRoute,
|
ProxyRoute,
|
||||||
@@ -278,6 +279,10 @@ export function listProxyRoutes(): Promise<ProxyRoute[]> {
|
|||||||
|
|
||||||
// ── Docker Management ──────────────────────────────────────────────
|
// ── Docker Management ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function listProjectImages(projectId: string): Promise<LocalImage[]> {
|
||||||
|
return get<LocalImage[]>(`/api/projects/${projectId}/images`);
|
||||||
|
}
|
||||||
|
|
||||||
export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> {
|
export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> {
|
||||||
return post<{ images_removed: number; space_reclaimed_mb: number }>('/api/docker/prune-images');
|
return post<{ images_removed: number; space_reclaimed_mb: number }>('/api/docker/prune-images');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,11 @@
|
|||||||
"enableProxy": "Enable Proxy",
|
"enableProxy": "Enable Proxy",
|
||||||
"accessListId": "NPM Access List ID",
|
"accessListId": "NPM Access List ID",
|
||||||
"accessListIdHelp": "Per-project override. 0 = use global default from NPM settings.",
|
"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)",
|
"cpuLimit": "CPU Limit (cores)",
|
||||||
"cpuLimitHelp": "e.g., 0.5, 1, 2. Leave 0 for unlimited",
|
"cpuLimitHelp": "e.g., 0.5, 1, 2. Leave 0 for unlimited",
|
||||||
"memoryLimit": "Memory Limit (MB)",
|
"memoryLimit": "Memory Limit (MB)",
|
||||||
|
|||||||
@@ -105,6 +105,11 @@
|
|||||||
"enableProxy": "Включить прокси",
|
"enableProxy": "Включить прокси",
|
||||||
"accessListId": "ID списка доступа NPM",
|
"accessListId": "ID списка доступа NPM",
|
||||||
"accessListIdHelp": "Переопределение для проекта. 0 = использовать глобальное из настроек NPM.",
|
"accessListIdHelp": "Переопределение для проекта. 0 = использовать глобальное из настроек NPM.",
|
||||||
|
"localImages": "Локальные Docker-образы",
|
||||||
|
"imageTag": "Тег",
|
||||||
|
"imageId": "ID образа",
|
||||||
|
"imageSize": "Размер",
|
||||||
|
"imageCreated": "Создан",
|
||||||
"cpuLimit": "Лимит CPU (ядра)",
|
"cpuLimit": "Лимит CPU (ядра)",
|
||||||
"cpuLimitHelp": "напр., 0.5, 1, 2. Оставьте 0 для без ограничений",
|
"cpuLimitHelp": "напр., 0.5, 1, 2. Оставьте 0 для без ограничений",
|
||||||
"memoryLimit": "Лимит памяти (МБ)",
|
"memoryLimit": "Лимит памяти (МБ)",
|
||||||
|
|||||||
@@ -262,6 +262,15 @@ export interface ProxyHealth {
|
|||||||
error?: string;
|
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. */
|
/** An NPM access list for proxy authentication. */
|
||||||
export interface NpmAccessList {
|
export interface NpmAccessList {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { Project, Stage, Instance, Deploy } from '$lib/types';
|
import type { Project, Stage, Instance, Deploy, LocalImage } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||||
import InstanceCard from '$lib/components/InstanceCard.svelte';
|
import InstanceCard from '$lib/components/InstanceCard.svelte';
|
||||||
@@ -157,6 +157,7 @@
|
|||||||
}
|
}
|
||||||
let tagsLoading = $state(false);
|
let tagsLoading = $state(false);
|
||||||
let settingsDomain = $state('');
|
let settingsDomain = $state('');
|
||||||
|
let localImages = $state<LocalImage[]>([]);
|
||||||
|
|
||||||
let showDeleteConfirm = $state(false);
|
let showDeleteConfirm = $state(false);
|
||||||
|
|
||||||
@@ -198,6 +199,10 @@
|
|||||||
const settings = await api.getSettings();
|
const settings = await api.getSettings();
|
||||||
settingsDomain = settings.domain ?? '';
|
settingsDomain = settings.domain ?? '';
|
||||||
} catch { /* non-critical */ }
|
} catch { /* non-critical */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
localImages = await api.listProjectImages(projectId);
|
||||||
|
} catch { localImages = []; }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
|
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -582,6 +587,37 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Local Docker Images -->
|
||||||
|
{#if localImages.length > 0}
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.localImages')}</h2>
|
||||||
|
<div class="mt-4 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||||
|
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||||
|
<thead class="bg-[var(--surface-card-hover)]">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageTag')}</th>
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageId')}</th>
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageSize')}</th>
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageCreated')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||||
|
{#each localImages as img (img.id + img.tag)}
|
||||||
|
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-mono text-[var(--text-secondary)]">{img.tag || 'untagged'}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-xs font-mono text-[var(--text-tertiary)]">{img.id.substring(7, 19)}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{(img.size / (1024 * 1024)).toFixed(1)} MB</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm text-[var(--text-tertiary)]">{new Date(img.created * 1000).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Deploy History Timeline -->
|
<!-- Deploy History Timeline -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
|
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user