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