@@ -110,6 +110,13 @@ func (s *Server) Setup() {
110110 return func (c echo.Context ) error {
111111 ctxName := c .Param ("context" )
112112 if strings .TrimSpace (ctxName ) != "" {
113+ // URL decode the context name to handle special characters like @
114+ ctxName , err := url .PathUnescape (ctxName )
115+ if err != nil {
116+ return c .JSON (http .StatusBadRequest , map [string ]string {
117+ "error" : fmt .Sprintf ("failed to decode context name: %v" , err ),
118+ })
119+ }
113120 proxy , err := s .getOrCreateK8sProxyForContext (ctxName )
114121 if err != nil {
115122 status := http .StatusInternalServerError
@@ -154,6 +161,14 @@ func (s *Server) Setup() {
154161 })
155162 }
156163
164+ // URL decode the context name to handle special characters like @
165+ ctxName , err := url .PathUnescape (ctxName )
166+ if err != nil {
167+ return c .JSON (http .StatusBadRequest , map [string ]string {
168+ "error" : fmt .Sprintf ("failed to decode context name: %v" , err ),
169+ })
170+ }
171+
157172 proxy , err := s .getOrCreateK8sProxyForContext (ctxName )
158173 if err != nil {
159174 status := http .StatusInternalServerError
@@ -1062,6 +1077,29 @@ func (s *Server) Setup() {
10621077 })
10631078 })
10641079
1080+ // Add endpoints for listing Helm releases (context-aware) with optional Table response
1081+ // Support trailing resource segment to match client list path construction
1082+ s .echo .GET ("/api/:context/helm/releases/releases" , func (c echo.Context ) error {
1083+ proxy , ok := getProxyFromContext (c )
1084+ if ! ok {
1085+ return c .JSON (http .StatusBadRequest , map [string ]string {"error" : "missing proxy in context" })
1086+ }
1087+ return s .handleHelmReleasesList (c , proxy , "" )
1088+ })
1089+
1090+ // Support trailing resource segment for namespaced path as well
1091+ s .echo .GET ("/api/:context/helm/releases/namespaces/:namespace/releases" , func (c echo.Context ) error {
1092+ proxy , ok := getProxyFromContext (c )
1093+ if ! ok {
1094+ return c .JSON (http .StatusBadRequest , map [string ]string {"error" : "missing proxy in context" })
1095+ }
1096+ ns := c .Param ("namespace" )
1097+ if strings .EqualFold (ns , "all-namespaces" ) {
1098+ ns = ""
1099+ }
1100+ return s .handleHelmReleasesList (c , proxy , ns )
1101+ })
1102+
10651103 // Add endpoint for Helm release rollback (context-aware)
10661104 s .echo .POST ("/api/:context/helm/rollback/:namespace/:name/:revision" , func (c echo.Context ) error {
10671105 proxy , ok := getProxyFromContext (c )
@@ -1169,6 +1207,124 @@ func (s *Server) getOrCreateK8sProxyForContext(contextName string) (*KubernetesP
11691207 return proxy , nil
11701208}
11711209
1210+ // handleHelmReleasesList lists Helm releases and returns either a Kubernetes Table or a plain List
1211+ func (s * Server ) handleHelmReleasesList (c echo.Context , proxy * KubernetesProxy , namespace string ) error {
1212+ hc , err := helm .NewClient (proxy .k8sClient .Config , "" )
1213+ if err != nil {
1214+ return c .JSON (http .StatusInternalServerError , map [string ]string {
1215+ "error" : fmt .Sprintf ("failed to create helm client: %v" , err ),
1216+ })
1217+ }
1218+
1219+ releases , err := hc .ListReleases (c .Request ().Context (), namespace )
1220+ if err != nil {
1221+ return c .JSON (http .StatusInternalServerError , map [string ]string {
1222+ "error" : fmt .Sprintf ("Failed to list Helm releases: %v" , err ),
1223+ })
1224+ }
1225+
1226+ accept := c .Request ().Header .Get ("Accept" )
1227+ if strings .Contains (accept , "as=Table;g=meta.k8s.io;v=v1" ) {
1228+ table := buildHelmReleasesTable (releases )
1229+ return c .JSON (http .StatusOK , table )
1230+ }
1231+
1232+ // Default JSON list fallback
1233+ items := make ([]map [string ]interface {}, 0 , len (releases ))
1234+ for _ , rel := range releases {
1235+ items = append (items , buildHelmReleaseObject (rel ))
1236+ }
1237+ return c .JSON (http .StatusOK , map [string ]interface {}{
1238+ "kind" : "List" ,
1239+ "apiVersion" : "v1" ,
1240+ "items" : items ,
1241+ })
1242+ }
1243+
1244+ // buildHelmReleaseObject converts a Helm release to a Kubernetes-like object
1245+ func buildHelmReleaseObject (release * helm.Release ) map [string ]interface {} {
1246+ return map [string ]interface {}{
1247+ "apiVersion" : "helm.sh/v3" ,
1248+ "kind" : "Release" ,
1249+ "metadata" : map [string ]interface {}{
1250+ "name" : release .Name ,
1251+ "namespace" : release .Namespace ,
1252+ "creationTimestamp" : release .Updated .Format (time .RFC3339 ),
1253+ },
1254+ "spec" : map [string ]interface {}{
1255+ "chart" : release .Chart ,
1256+ "chartVersion" : release .ChartVersion ,
1257+ "values" : release .Values ,
1258+ },
1259+ "status" : map [string ]interface {}{
1260+ "status" : release .Status ,
1261+ "revision" : release .Revision ,
1262+ "appVersion" : release .AppVersion ,
1263+ "notes" : release .Notes ,
1264+ },
1265+ }
1266+ }
1267+
1268+ // buildHelmReleasesTable constructs a meta.k8s.io/v1 Table response for Helm releases
1269+ func buildHelmReleasesTable (releases []* helm.Release ) map [string ]interface {} {
1270+ columnDefinitions := []map [string ]interface {}{
1271+ {"name" : "Name" , "type" : "string" , "format" : "name" },
1272+ {"name" : "Chart" , "type" : "string" },
1273+ {"name" : "App Version" , "type" : "string" },
1274+ {"name" : "Status" , "type" : "string" },
1275+ {"name" : "Revision" , "type" : "string" },
1276+ {"name" : "Age" , "type" : "string" },
1277+ }
1278+
1279+ rows := make ([]map [string ]interface {}, 0 , len (releases ))
1280+ now := time .Now ()
1281+ for _ , rel := range releases {
1282+ age := humanizeDuration (now .Sub (rel .Updated ))
1283+ chart := rel .Chart
1284+ if rel .ChartVersion != "" {
1285+ chart = fmt .Sprintf ("%s (%s)" , rel .Chart , rel .ChartVersion )
1286+ }
1287+ cells := []interface {}{
1288+ rel .Name ,
1289+ chart ,
1290+ rel .AppVersion ,
1291+ rel .Status ,
1292+ fmt .Sprintf ("%d" , rel .Revision ),
1293+ age ,
1294+ }
1295+ rows = append (rows , map [string ]interface {}{
1296+ "cells" : cells ,
1297+ "object" : buildHelmReleaseObject (rel ),
1298+ })
1299+ }
1300+
1301+ return map [string ]interface {}{
1302+ "kind" : "Table" ,
1303+ "apiVersion" : "meta.k8s.io/v1" ,
1304+ "columnDefinitions" : columnDefinitions ,
1305+ "rows" : rows ,
1306+ }
1307+ }
1308+
1309+ // humanizeDuration returns a short human-readable duration like 5m, 2h, 3d
1310+ func humanizeDuration (d time.Duration ) string {
1311+ if d < time .Minute {
1312+ s := int (d .Seconds ())
1313+ if s <= 0 {
1314+ return "0s"
1315+ }
1316+ return fmt .Sprintf ("%ds" , s )
1317+ }
1318+ if d < time .Hour {
1319+ return fmt .Sprintf ("%dm" , int (d .Minutes ()))
1320+ }
1321+ if d < 24 * time .Hour {
1322+ return fmt .Sprintf ("%dh" , int (d .Hours ()))
1323+ }
1324+ days := int (d .Hours ()) / 24
1325+ return fmt .Sprintf ("%dd" , days )
1326+ }
1327+
11721328// downloadAndExtractArtifact downloads and extracts a Flux source artifact
11731329func (s * Server ) downloadAndExtractArtifact (ctx context.Context , client * kubernetes.Client , artifactURL string ) (string , error ) {
11741330 // Create a temporary directory
0 commit comments