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