From 443207191dbf298c1e22e0ad51b8c0d6b9e92019 Mon Sep 17 00:00:00 2001 From: Andrei Pohilko Date: Tue, 17 Mar 2026 16:47:59 +0000 Subject: [PATCH] feat: display container images summary on Images tab (#83) Add a new "Images" tab on the release details page that extracts and displays all container images (including init containers) from the release manifest. Images are grouped by image string and show the associated resource and container name. Closes #83 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/API/releases.ts | 18 +++++ .../components/revision/RevisionDetails.tsx | 6 ++ .../components/revision/RevisionImages.tsx | 71 +++++++++++++++++ pkg/dashboard/api.go | 1 + pkg/dashboard/handlers/helmHandlers.go | 10 +++ pkg/dashboard/objects/data.go | 76 +++++++++++++++++++ 6 files changed, 182 insertions(+) create mode 100644 frontend/src/components/revision/RevisionImages.tsx diff --git a/frontend/src/API/releases.ts b/frontend/src/API/releases.ts index 0fa605f..7fd405f 100644 --- a/frontend/src/API/releases.ts +++ b/frontend/src/API/releases.ts @@ -78,6 +78,24 @@ export function useGetReleaseManifest({ }); } +export interface ContainerImage { + resource: string; + kind: string; + container: string; + image: string; +} + +export function useGetImages(ns: string, name: string) { + return useQuery({ + queryKey: ["images", ns, name], + queryFn: () => + apiService.fetchWithSafeDefaults({ + url: `/api/helm/releases/${ns}/${name}/images`, + fallback: [], + }), + }); +} + // List of installed k8s resources for this release export function useGetResources(ns: string, name: string, enabled?: boolean) { return useQuery({ diff --git a/frontend/src/components/revision/RevisionDetails.tsx b/frontend/src/components/revision/RevisionDetails.tsx index fd132c7..d702f41 100644 --- a/frontend/src/components/revision/RevisionDetails.tsx +++ b/frontend/src/components/revision/RevisionDetails.tsx @@ -30,6 +30,7 @@ import Modal, { ModalButtonStyle } from "../modal/Modal"; import Spinner from "../Spinner"; import Tabs from "../Tabs"; import RevisionDiff from "./RevisionDiff"; +import RevisionImages from "./RevisionImages"; import RevisionResource from "./RevisionResource"; type RevisionTagProps = { @@ -78,6 +79,11 @@ const RevisionDetails = ({ label: "Notes", content: , }, + { + value: "images", + label: "Images", + content: , + }, ]; const { context, namespace, chart } = useParams(); const tab = searchParams.get("tab"); diff --git a/frontend/src/components/revision/RevisionImages.tsx b/frontend/src/components/revision/RevisionImages.tsx new file mode 100644 index 0000000..5889b34 --- /dev/null +++ b/frontend/src/components/revision/RevisionImages.tsx @@ -0,0 +1,71 @@ +import { useMemo } from "react"; +import { useParams } from "react-router"; + +import { type ContainerImage, useGetImages } from "../../API/releases"; +import Spinner from "../Spinner"; + +export default function RevisionImages() { + const { namespace = "", chart = "" } = useParams(); + const { data: images, isLoading } = useGetImages(namespace, chart); + + const grouped = useMemo(() => { + if (!images) return []; + const map = new Map(); + for (const img of images) { + const list = map.get(img.image) ?? []; + list.push(img); + map.set(img.image, list); + } + return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0])); + }, [images]); + + if (isLoading) return ; + + if (!grouped.length) { + return ( +
+ No container images found in this release. +
+ ); + } + + return ( + + + + + + + + + + {grouped.map(([image, resources]) => + resources.map((r, i) => ( + + {i === 0 ? ( + + ) : null} + + + + )) + )} + +
IMAGERESOURCECONTAINER
+ {image} + + {r.kind}/{r.resource} + + {r.container} +
+ ); +} diff --git a/pkg/dashboard/api.go b/pkg/dashboard/api.go index e4c753b..61c6a6b 100644 --- a/pkg/dashboard/api.go +++ b/pkg/dashboard/api.go @@ -162,6 +162,7 @@ func configureHelms(api *gin.RouterGroup, data *objects.DataLayer) { rels.GET(":ns/:name/history", h.History) rels.GET(":ns/:name/:section", h.GetInfoSection) rels.GET(":ns/:name/resources", h.Resources) + rels.GET(":ns/:name/images", h.Images) rels.POST(":ns/:name/rollback", h.Rollback) rels.POST(":ns/:name/test", h.RunTests) diff --git a/pkg/dashboard/handlers/helmHandlers.go b/pkg/dashboard/handlers/helmHandlers.go index ca40a3c..b045da2 100644 --- a/pkg/dashboard/handlers/helmHandlers.go +++ b/pkg/dashboard/handlers/helmHandlers.go @@ -172,6 +172,16 @@ func (h *HelmHandler) Resources(c *gin.Context) { c.IndentedJSON(http.StatusOK, res) } +func (h *HelmHandler) Images(c *gin.Context) { + rel := h.getRelease(c) + if rel == nil { + return + } + + images := objects.ExtractImages(rel.Orig.Manifest) + c.IndentedJSON(http.StatusOK, images) +} + func (h *HelmHandler) RepoVersions(c *gin.Context) { qp, err := utils.GetQueryProps(c) if err != nil { diff --git a/pkg/dashboard/objects/data.go b/pkg/dashboard/objects/data.go index 3bfa726..b59adce 100644 --- a/pkg/dashboard/objects/data.go +++ b/pkg/dashboard/objects/data.go @@ -139,6 +139,82 @@ func ParseManifests(out string) ([]*v1.Carp, error) { return res, nil } +// ContainerImage represents a container image found in a release manifest. +type ContainerImage struct { + Resource string `json:"resource"` + Kind string `json:"kind"` + Container string `json:"container"` + Image string `json:"image"` +} + +// ExtractImages parses a manifest and returns all container images found. +func ExtractImages(manifest string) []ContainerImage { + dec := yaml.NewYAMLOrJSONDecoder(strings.NewReader(manifest), 4096) + var images []ContainerImage + for { + var tmp map[string]interface{} + if err := dec.Decode(&tmp); err != nil { + break + } + kind, _ := tmp["kind"].(string) + metadata, _ := tmp["metadata"].(map[string]interface{}) + name, _ := metadata["name"].(string) + + for _, podSpec := range findPodSpecs(kind, tmp) { + containers, _ := podSpec["containers"].([]interface{}) + images = extractFromContainers(images, kind, name, containers) + initContainers, _ := podSpec["initContainers"].([]interface{}) + images = extractFromContainers(images, kind, name, initContainers) + } + } + return images +} + +func extractFromContainers(images []ContainerImage, kind, resource string, containers []interface{}) []ContainerImage { + for _, c := range containers { + cMap, ok := c.(map[string]interface{}) + if !ok { + continue + } + img, _ := cMap["image"].(string) + cName, _ := cMap["name"].(string) + if img != "" { + images = append(images, ContainerImage{ + Resource: resource, + Kind: kind, + Container: cName, + Image: img, + }) + } + } + return images +} + +// findPodSpecs returns the pod spec(s) from a resource, depending on kind. +func findPodSpecs(kind string, obj map[string]interface{}) []map[string]interface{} { + spec, _ := obj["spec"].(map[string]interface{}) + if spec == nil { + return nil + } + switch kind { + case "Pod": + return []map[string]interface{}{spec} + case "Deployment", "StatefulSet", "DaemonSet", "ReplicaSet", "Job": + tpl, _ := spec["template"].(map[string]interface{}) + if ps, ok := tpl["spec"].(map[string]interface{}); ok { + return []map[string]interface{}{ps} + } + case "CronJob": + jobTpl, _ := spec["jobTemplate"].(map[string]interface{}) + jobSpec, _ := jobTpl["spec"].(map[string]interface{}) + tpl, _ := jobSpec["template"].(map[string]interface{}) + if ps, ok := tpl["spec"].(map[string]interface{}); ok { + return []map[string]interface{}{ps} + } + } + return nil +} + func (d *DataLayer) SetContext(ctx string) error { if d.KubeContext != ctx { err := d.Cache.Clear()