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) <noreply@anthropic.com>
This commit is contained in:
Andrei Pohilko
2026-03-17 16:47:59 +00:00
parent c5ae60a779
commit 443207191d
6 changed files with 182 additions and 0 deletions

View File

@@ -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<ContainerImage[]>({
queryKey: ["images", ns, name],
queryFn: () =>
apiService.fetchWithSafeDefaults<ContainerImage[]>({
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<StructuredResources[]>({

View File

@@ -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: <RevisionDiff latestRevision={latestRevision} />,
},
{
value: "images",
label: "Images",
content: <RevisionImages />,
},
];
const { context, namespace, chart } = useParams();
const tab = searchParams.get("tab");

View File

@@ -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<string, ContainerImage[]>();
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 <Spinner />;
if (!grouped.length) {
return (
<div className="mt-3 rounded-sm bg-white p-4 text-sm shadow-sm">
No container images found in this release.
</div>
);
}
return (
<table
cellPadding={6}
className="w-full border-separate border-spacing-y-2 text-xs font-semibold"
>
<thead className="h-8 rounded-sm bg-zinc-200 font-bold">
<tr>
<td className="rounded-sm pl-6">IMAGE</td>
<td>RESOURCE</td>
<td className="rounded-sm">CONTAINER</td>
</tr>
</thead>
<tbody className="mt-4 h-8 w-full rounded-sm bg-white">
{grouped.map(([image, resources]) =>
resources.map((r, i) => (
<tr
key={r.kind + r.resource + r.container}
className="min-h[70px] min-w-[100%] py-2 text-sm"
>
{i === 0 ? (
<td
className="w-1/2 rounded-sm pl-6 font-mono text-xs font-normal"
rowSpan={resources.length}
>
{image}
</td>
) : null}
<td className="text-sm font-normal">
{r.kind}/{r.resource}
</td>
<td className="rounded-sm text-sm font-normal text-gray-500">
{r.container}
</td>
</tr>
))
)}
</tbody>
</table>
);
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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()