mirror of https://github.com/portainer/portainer
feat(custom-templates): filter templates by edge [EE-6565] (#10979)
parent
441a8bbbbf
commit
2826a4ce39
|
@ -8,8 +8,11 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
|
"github.com/portainer/portainer/api/internal/slices"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @id CustomTemplateList
|
// @id CustomTemplateList
|
||||||
|
@ -21,6 +24,7 @@ import (
|
||||||
// @security jwt
|
// @security jwt
|
||||||
// @produce json
|
// @produce json
|
||||||
// @param type query []int true "Template types" Enums(1,2,3)
|
// @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"
|
// @success 200 {array} portainer.CustomTemplate "Success"
|
||||||
// @failure 500 "Server error"
|
// @failure 500 "Server error"
|
||||||
// @router /custom_templates [get]
|
// @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)
|
return httperror.BadRequest("Invalid Custom template type", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
edge := retrieveEdgeParam(r)
|
||||||
|
|
||||||
customTemplates, err := handler.DataStore.CustomTemplate().ReadAll()
|
customTemplates, err := handler.DataStore.CustomTemplate().ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
|
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)
|
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)
|
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) {
|
func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) {
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -8,3 +8,16 @@ func Map[T, U any](s []T, f func(T) U) []U {
|
||||||
}
|
}
|
||||||
return result
|
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]
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,8 +11,8 @@ function CustomTemplateServiceFactory($sanitize, CustomTemplates, FileUploadServ
|
||||||
return CustomTemplates.get({ id }).$promise;
|
return CustomTemplates.get({ id }).$promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.customTemplates = async function customTemplates(type) {
|
service.customTemplates = async function customTemplates(type, edge = false) {
|
||||||
const templates = await CustomTemplates.query({ type }).$promise;
|
const templates = await CustomTemplates.query({ type, edge }).$promise;
|
||||||
templates.forEach((template) => {
|
templates.forEach((template) => {
|
||||||
if (template.Note) {
|
if (template.Note) {
|
||||||
template.Note = $('<p>').html($sanitize(template.Note)).find('img').remove().end().html();
|
template.Note = $('<p>').html($sanitize(template.Note)).find('img').remove().end().html();
|
||||||
|
|
|
@ -36,8 +36,9 @@ export function TemplateFieldset({
|
||||||
}, [initialValues, values.template?.Id]);
|
}, [initialValues, values.template?.Id]);
|
||||||
|
|
||||||
const templatesQuery = useCustomTemplates({
|
const templatesQuery = useCustomTemplates({
|
||||||
select: (templates) =>
|
params: {
|
||||||
templates.filter((template) => template.EdgeTemplate),
|
edge: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -111,8 +112,9 @@ function TemplateSelector({
|
||||||
error?: string;
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
const templatesQuery = useCustomTemplates({
|
const templatesQuery = useCustomTemplates({
|
||||||
select: (templates) =>
|
params: {
|
||||||
templates.filter((template) => template.EdgeTemplate),
|
edge: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!templatesQuery.data) {
|
if (!templatesQuery.data) {
|
||||||
|
|
|
@ -9,8 +9,8 @@ import { confirmDelete } from '@@/modals/confirm';
|
||||||
|
|
||||||
export function ListView() {
|
export function ListView() {
|
||||||
const templatesQuery = useCustomTemplates({
|
const templatesQuery = useCustomTemplates({
|
||||||
select(templates) {
|
params: {
|
||||||
return templates.filter((t) => t.EdgeTemplate);
|
edge: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const deleteMutation = useDeleteTemplateMutation();
|
const deleteMutation = useDeleteTemplateMutation();
|
||||||
|
|
|
@ -27,7 +27,11 @@ export function useValidation({
|
||||||
}) {
|
}) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const gitCredentialsQuery = useGitCredentials(user.Id);
|
const gitCredentialsQuery = useGitCredentials(user.Id);
|
||||||
const customTemplatesQuery = useCustomTemplates();
|
const customTemplatesQuery = useCustomTemplates({
|
||||||
|
params: {
|
||||||
|
edge: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
@ -25,7 +25,11 @@ export function useValidation({
|
||||||
}) {
|
}) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const gitCredentialsQuery = useGitCredentials(user.Id);
|
const gitCredentialsQuery = useGitCredentials(user.Id);
|
||||||
const customTemplatesQuery = useCustomTemplates();
|
const customTemplatesQuery = useCustomTemplates({
|
||||||
|
params: {
|
||||||
|
edge: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { CustomTemplate } from '../types';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
base: () => ['custom-templates'] as const,
|
base: () => ['custom-templates'] as const,
|
||||||
|
list: (params: unknown) => [...queryKeys.base(), { params }] as const,
|
||||||
item: (id: CustomTemplate['Id']) => [...queryKeys.base(), id] as const,
|
item: (id: CustomTemplate['Id']) => [...queryKeys.base(), id] as const,
|
||||||
file: (id: CustomTemplate['Id'], options: { git: boolean }) =>
|
file: (id: CustomTemplate['Id'], options: { git: boolean }) =>
|
||||||
[...queryKeys.item(id), 'file', options] as const,
|
[...queryKeys.item(id), 'file', options] as const,
|
||||||
|
|
|
@ -2,26 +2,45 @@ import { useQuery } from 'react-query';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { withGlobalError } from '@/react-tools/react-query';
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import { StackType } from '@/react/common/stacks/types';
|
||||||
|
|
||||||
import { CustomTemplate } from '../types';
|
import { CustomTemplate } from '../types';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { queryKeys } from './query-keys';
|
||||||
import { buildUrl } from './build-url';
|
import { buildUrl } from './build-url';
|
||||||
|
|
||||||
export async function getCustomTemplates() {
|
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<T = Array<CustomTemplate>>({
|
||||||
|
select,
|
||||||
|
params,
|
||||||
|
}: { params?: Params; select?(templates: Array<CustomTemplate>): T } = {}) {
|
||||||
|
return useQuery(queryKeys.list(params), () => getCustomTemplates(params), {
|
||||||
|
select,
|
||||||
|
...withGlobalError('Unable to retrieve custom templates'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCustomTemplates({ type, edge = false }: Params = {}) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<CustomTemplate[]>(buildUrl());
|
const { data } = await axios.get<CustomTemplate[]>(buildUrl(), {
|
||||||
|
params: {
|
||||||
|
// deconstruct to make sure we don't pass other params
|
||||||
|
type,
|
||||||
|
edge,
|
||||||
|
},
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseAxiosError(e, 'Unable to get custom templates');
|
throw parseAxiosError(e, 'Unable to get custom templates');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCustomTemplates<T = Array<CustomTemplate>>({
|
|
||||||
select,
|
|
||||||
}: { select?(templates: Array<CustomTemplate>): T } = {}) {
|
|
||||||
return useQuery(queryKeys.base(), () => getCustomTemplates(), {
|
|
||||||
select,
|
|
||||||
...withGlobalError('Unable to retrieve custom templates'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in New Issue