diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go index b9150a5c2..7ed8fb6a7 100644 --- a/api/http/handler/customtemplates/customtemplate_list.go +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -8,8 +8,11 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/slices" httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/rs/zerolog/log" ) // @id CustomTemplateList @@ -21,6 +24,7 @@ import ( // @security jwt // @produce json // @param type query []int true "Template types" Enums(1,2,3) +// @param edge query boolean false "Filter by edge templates" // @success 200 {array} portainer.CustomTemplate "Success" // @failure 500 "Server error" // @router /custom_templates [get] @@ -30,6 +34,8 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques return httperror.BadRequest("Invalid Custom template type", err) } + edge := retrieveEdgeParam(r) + customTemplates, err := handler.DataStore.CustomTemplate().ReadAll() if err != nil { return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) @@ -63,9 +69,37 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques customTemplates = filterByType(customTemplates, templateTypes) + if edge != nil { + customTemplates = slices.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool { + return customTemplate.EdgeTemplate == *edge + }) + } + + for i := range customTemplates { + customTemplate := &customTemplates[i] + if customTemplate.GitConfig != nil && customTemplate.GitConfig.Authentication != nil { + customTemplate.GitConfig.Authentication.Password = "" + } + } + return response.JSON(w, customTemplates) } +func retrieveEdgeParam(r *http.Request) *bool { + var edge *bool + edgeParam, _ := request.RetrieveQueryParameter(r, "edge", true) + if edgeParam != "" { + edgeVal, err := strconv.ParseBool(edgeParam) + if err != nil { + log.Warn().Err(err).Msg("failed parsing edge param") + return nil + } + + edge = &edgeVal + } + return edge +} + func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) { err := r.ParseForm() if err != nil { diff --git a/api/internal/slices/slices.go b/api/internal/slices/slices.go index a2677cd87..d6022afca 100644 --- a/api/internal/slices/slices.go +++ b/api/internal/slices/slices.go @@ -8,3 +8,16 @@ func Map[T, U any](s []T, f func(T) U) []U { } return result } + +// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true +func Filter[T any](s []T, predicate func(T) bool) []T { + n := 0 + for _, v := range s { + if predicate(v) { + s[n] = v + n++ + } + } + + return s[:n] +} diff --git a/api/internal/slices/slices_test.go b/api/internal/slices/slices_test.go new file mode 100644 index 000000000..513c2474e --- /dev/null +++ b/api/internal/slices/slices_test.go @@ -0,0 +1,131 @@ +package slices + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +type filterTestCase[T any] struct { + name string + input []T + expected []T + predicate func(T) bool +} + +func TestFilter(t *testing.T) { + + intTestCases := []filterTestCase[int]{ + { + name: "Filter even numbers", + input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + expected: []int{2, 4, 6, 8}, + + predicate: func(n int) bool { + return n%2 == 0 + }, + }, + { + name: "Filter odd numbers", + input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + expected: []int{1, 3, 5, 7, 9}, + + predicate: func(n int) bool { + return n%2 != 0 + }, + }, + } + + runTestCases(t, intTestCases) + + stringTestCases := []filterTestCase[string]{ + { + name: "Filter strings starting with 'A'", + input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + expected: []string{"Apple", "Avocado", "Apricot"}, + predicate: func(s string) bool { + return s[0] == 'A' + }, + }, + { + name: "Filter strings longer than 5 characters", + input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + expected: []string{"Banana", "Avocado", "Grapes", "Apricot"}, + predicate: func(s string) bool { + return len(s) > 5 + }, + }, + } + + runTestCases(t, stringTestCases) + +} + +func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + is := assert.New(t) + result := Filter(testCase.input, testCase.predicate) + + is.Equal(len(testCase.expected), len(result)) + is.ElementsMatch(testCase.expected, result) + }) + } +} + +func TestMap(t *testing.T) { + intTestCases := []struct { + name string + input []int + expected []string + mapper func(int) string + }{ + { + name: "Map integers to strings", + input: []int{1, 2, 3, 4, 5}, + expected: []string{"1", "2", "3", "4", "5"}, + mapper: func(n int) string { + return strconv.Itoa(n) + }, + }, + } + + runMapTestCases(t, intTestCases) + + stringTestCases := []struct { + name string + input []string + expected []int + mapper func(string) int + }{ + { + name: "Map strings to integers", + input: []string{"1", "2", "3", "4", "5"}, + expected: []int{1, 2, 3, 4, 5}, + mapper: func(s string) int { + n, _ := strconv.Atoi(s) + return n + }, + }, + } + + runMapTestCases(t, stringTestCases) +} + +func runMapTestCases[T, U any](t *testing.T, testCases []struct { + name string + input []T + expected []U + mapper func(T) U +}) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + is := assert.New(t) + result := Map(testCase.input, testCase.mapper) + + is.Equal(len(testCase.expected), len(result)) + is.ElementsMatch(testCase.expected, result) + }) + } +} diff --git a/app/portainer/services/api/customTemplate.js b/app/portainer/services/api/customTemplate.js index 0bb620996..ab93050e9 100644 --- a/app/portainer/services/api/customTemplate.js +++ b/app/portainer/services/api/customTemplate.js @@ -11,8 +11,8 @@ function CustomTemplateServiceFactory($sanitize, CustomTemplates, FileUploadServ return CustomTemplates.get({ id }).$promise; }; - service.customTemplates = async function customTemplates(type) { - const templates = await CustomTemplates.query({ type }).$promise; + service.customTemplates = async function customTemplates(type, edge = false) { + const templates = await CustomTemplates.query({ type, edge }).$promise; templates.forEach((template) => { if (template.Note) { template.Note = $('

').html($sanitize(template.Note)).find('img').remove().end().html(); diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx index 1f1bd2de7..7015a6709 100644 --- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx @@ -36,8 +36,9 @@ export function TemplateFieldset({ }, [initialValues, values.template?.Id]); const templatesQuery = useCustomTemplates({ - select: (templates) => - templates.filter((template) => template.EdgeTemplate), + params: { + edge: true, + }, }); return ( @@ -111,8 +112,9 @@ function TemplateSelector({ error?: string; }) { const templatesQuery = useCustomTemplates({ - select: (templates) => - templates.filter((template) => template.EdgeTemplate), + params: { + edge: true, + }, }); if (!templatesQuery.data) { diff --git a/app/react/edge/templates/custom-templates/ListView/ListView.tsx b/app/react/edge/templates/custom-templates/ListView/ListView.tsx index a7389d0e5..2e1b83294 100644 --- a/app/react/edge/templates/custom-templates/ListView/ListView.tsx +++ b/app/react/edge/templates/custom-templates/ListView/ListView.tsx @@ -9,8 +9,8 @@ import { confirmDelete } from '@@/modals/confirm'; export function ListView() { const templatesQuery = useCustomTemplates({ - select(templates) { - return templates.filter((t) => t.EdgeTemplate); + params: { + edge: true, }, }); const deleteMutation = useDeleteTemplateMutation(); diff --git a/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx b/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx index 479f2c101..b36d0290a 100644 --- a/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx +++ b/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx @@ -27,7 +27,11 @@ export function useValidation({ }) { const { user } = useCurrentUser(); const gitCredentialsQuery = useGitCredentials(user.Id); - const customTemplatesQuery = useCustomTemplates(); + const customTemplatesQuery = useCustomTemplates({ + params: { + edge: undefined, + }, + }); return useMemo( () => diff --git a/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx b/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx index a4df5b3ae..246c02436 100644 --- a/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx +++ b/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx @@ -25,7 +25,11 @@ export function useValidation({ }) { const { user } = useCurrentUser(); const gitCredentialsQuery = useGitCredentials(user.Id); - const customTemplatesQuery = useCustomTemplates(); + const customTemplatesQuery = useCustomTemplates({ + params: { + edge: undefined, + }, + }); return useMemo( () => diff --git a/app/react/portainer/templates/custom-templates/queries/query-keys.ts b/app/react/portainer/templates/custom-templates/queries/query-keys.ts index b5cad853a..20f515e13 100644 --- a/app/react/portainer/templates/custom-templates/queries/query-keys.ts +++ b/app/react/portainer/templates/custom-templates/queries/query-keys.ts @@ -2,6 +2,7 @@ import { CustomTemplate } from '../types'; export const queryKeys = { base: () => ['custom-templates'] as const, + list: (params: unknown) => [...queryKeys.base(), { params }] as const, item: (id: CustomTemplate['Id']) => [...queryKeys.base(), id] as const, file: (id: CustomTemplate['Id'], options: { git: boolean }) => [...queryKeys.item(id), 'file', options] as const, diff --git a/app/react/portainer/templates/custom-templates/queries/useCustomTemplates.ts b/app/react/portainer/templates/custom-templates/queries/useCustomTemplates.ts index 3ffe213c0..a5d457f5a 100644 --- a/app/react/portainer/templates/custom-templates/queries/useCustomTemplates.ts +++ b/app/react/portainer/templates/custom-templates/queries/useCustomTemplates.ts @@ -2,26 +2,45 @@ import { useQuery } from 'react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { withGlobalError } from '@/react-tools/react-query'; +import { StackType } from '@/react/common/stacks/types'; import { CustomTemplate } from '../types'; import { queryKeys } from './query-keys'; import { buildUrl } from './build-url'; -export async function getCustomTemplates() { - try { - const { data } = await axios.get(buildUrl()); - return data; - } catch (e) { - throw parseAxiosError(e, 'Unable to get custom templates'); - } -} +type Params = { + type?: StackType[]; + /** + * filter edge templates + * true if should show only edge templates + * false if should show only non-edge templates + * undefined if should show all templates + */ + edge?: boolean; +}; export function useCustomTemplates>({ select, -}: { select?(templates: Array): T } = {}) { - return useQuery(queryKeys.base(), () => getCustomTemplates(), { + params, +}: { params?: Params; select?(templates: Array): T } = {}) { + return useQuery(queryKeys.list(params), () => getCustomTemplates(params), { select, ...withGlobalError('Unable to retrieve custom templates'), }); } + +async function getCustomTemplates({ type, edge = false }: Params = {}) { + try { + const { data } = await axios.get(buildUrl(), { + params: { + // deconstruct to make sure we don't pass other params + type, + edge, + }, + }); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to get custom templates'); + } +}