diff --git a/api/agent/version.go b/api/agent/version.go new file mode 100644 index 000000000..92926cf93 --- /dev/null +++ b/api/agent/version.go @@ -0,0 +1,71 @@ +package agent + +import ( + "crypto/tls" + "errors" + "fmt" + "net/http" + netUrl "net/url" + "strconv" + "time" + + portainer "github.com/portainer/portainer/api" +) + +// GetAgentVersionAndPlatform returns the agent version and platform +// +// it sends a ping to the agent and parses the version and platform from the headers +func GetAgentVersionAndPlatform(url string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { + httpCli := &http.Client{ + Timeout: 3 * time.Second, + } + + if tlsConfig != nil { + httpCli.Transport = &http.Transport{ + TLSClientConfig: tlsConfig, + } + } + + parsedURL, err := netUrl.Parse(fmt.Sprintf("%s/ping", url)) + if err != nil { + return 0, "", err + } + + parsedURL.Scheme = "https" + + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return 0, "", err + } + + resp, err := httpCli.Do(req) + if err != nil { + return 0, "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode) + } + + version := resp.Header.Get(portainer.PortainerAgentHeader) + if version == "" { + return 0, "", errors.New("Version Header is missing") + } + + agentPlatformHeader := resp.Header.Get(portainer.HTTPResponseAgentPlatform) + if agentPlatformHeader == "" { + return 0, "", errors.New("Agent Platform Header is missing") + } + + agentPlatformNumber, err := strconv.Atoi(agentPlatformHeader) + if err != nil { + return 0, "", err + } + + if agentPlatformNumber == 0 { + return 0, "", errors.New("Agent platform is invalid") + } + + return portainer.AgentPlatform(agentPlatformNumber), version, nil +} diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index bd3bceb08..3b262447f 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -27,6 +27,9 @@ ], "endpoints": [ { + "Agent": { + "Version": "" + }, "AuthorizedTeams": null, "AuthorizedUsers": null, "AzureCredentials": { diff --git a/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go b/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go index 7e4bada51..90865021d 100644 --- a/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go +++ b/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go @@ -85,6 +85,9 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http endpoint.Type = agentPlatform } + version := r.Header.Get(portainer.PortainerAgentHeader) + endpoint.Agent.Version = version + endpoint.LastCheckInDate = time.Now().Unix() err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) diff --git a/api/http/handler/endpoints/endpoint_agent_versions.go b/api/http/handler/endpoints/endpoint_agent_versions.go new file mode 100644 index 000000000..9d4e62414 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_agent_versions.go @@ -0,0 +1,50 @@ +package endpoints + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/set" +) + +// @id AgentVersions +// @summary List agent versions +// @description List all agent versions based on the current user authorizations and query parameters. +// @description **Access policy**: restricted +// @tags endpoints +// @security ApiKeyAuth +// @security jwt +// @produce json +// @success 200 {array} string "List of available agent versions" +// @failure 500 "Server error" +// @router /endpoints/agent_versions [get] + +func (handler *Handler) agentVersions(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) + } + + endpoints, err := handler.DataStore.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Unable to retrieve environments from the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) + + agentVersions := set.Set[string]{} + for _, endpoint := range filteredEndpoints { + if endpoint.Agent.Version != "" { + agentVersions[endpoint.Agent.Version] = true + } + } + + return response.JSON(w, agentVersions.Keys()) +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 7b6d58ecf..5b1dde10d 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -1,20 +1,19 @@ package endpoints import ( + "crypto/tls" "errors" - "fmt" "net/http" - "net/url" "runtime" "strconv" "strings" - "time" "github.com/gofrs/uuid" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/agent" "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/internal/edge" @@ -245,6 +244,7 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * } func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + var err error switch payload.EndpointCreationType { case azureEnvironment: return handler.createAzureEndpoint(payload) @@ -257,15 +257,25 @@ func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portain } endpointType := portainer.DockerEnvironment + var agentVersion string if payload.EndpointCreationType == agentEnvironment { payload.URL = "tcp://" + normalizeAgentAddress(payload.URL) - agentPlatform, err := handler.pingAndCheckPlatform(payload) + var tlsConfig *tls.Config + if payload.TLS { + tlsConfig, err = crypto.CreateTLSConfigurationFromBytes(payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipVerify, payload.TLSSkipClientVerify) + if err != nil { + return nil, httperror.InternalServerError("Unable to create TLS configuration", err) + } + } + + agentPlatform, version, err := agent.GetAgentVersionAndPlatform(payload.URL, tlsConfig) if err != nil { return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to get environment type", err} } + agentVersion = version if agentPlatform == portainer.AgentPlatformDocker { endpointType = portainer.AgentOnDockerEnvironment } else if agentPlatform == portainer.AgentPlatformKubernetes { @@ -275,7 +285,7 @@ func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portain } if payload.TLS { - return handler.createTLSSecuredEndpoint(payload, endpointType) + return handler.createTLSSecuredEndpoint(payload, endpointType, agentVersion) } return handler.createUnsecuredEndpoint(payload) } @@ -447,7 +457,7 @@ func (handler *Handler) createKubernetesEndpoint(payload *endpointCreatePayload) return endpoint, nil } -func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload, endpointType portainer.EndpointType) (*portainer.Endpoint, *httperror.HandlerError) { +func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload, endpointType portainer.EndpointType, agentVersion string) (*portainer.Endpoint, *httperror.HandlerError) { endpointID := handler.DataStore.Endpoint().GetNextIdentifier() endpoint := &portainer.Endpoint{ ID: portainer.EndpointID(endpointID), @@ -470,6 +480,8 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload, IsEdgeDevice: payload.IsEdgeDevice, } + endpoint.Agent.Version = agentVersion + err := handler.storeTLSFiles(endpoint, payload) if err != nil { return nil, err @@ -563,58 +575,3 @@ func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *end return nil } - -func (handler *Handler) pingAndCheckPlatform(payload *endpointCreatePayload) (portainer.AgentPlatform, error) { - httpCli := &http.Client{ - Timeout: 3 * time.Second, - } - - if payload.TLS { - tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipVerify, payload.TLSSkipClientVerify) - if err != nil { - return 0, err - } - - httpCli.Transport = &http.Transport{ - TLSClientConfig: tlsConfig, - } - } - - url, err := url.Parse(fmt.Sprintf("%s/ping", payload.URL)) - if err != nil { - return 0, err - } - - url.Scheme = "https" - - req, err := http.NewRequest(http.MethodGet, url.String(), nil) - if err != nil { - return 0, err - } - - resp, err := httpCli.Do(req) - if err != nil { - return 0, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusNoContent { - return 0, fmt.Errorf("Failed request with status %d", resp.StatusCode) - } - - agentPlatformHeader := resp.Header.Get(portainer.HTTPResponseAgentPlatform) - if agentPlatformHeader == "" { - return 0, errors.New("Agent Platform Header is missing") - } - - agentPlatformNumber, err := strconv.Atoi(agentPlatformHeader) - if err != nil { - return 0, err - } - - if agentPlatformNumber == 0 { - return 0, errors.New("Agent platform is invalid") - } - - return portainer.AgentPlatform(agentPlatformNumber), nil -} diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 024aafb44..a16ab228c 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -41,6 +41,7 @@ const ( // @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags" // @param endpointIds query []int false "will return only these environments(endpoints)" // @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned" +// @param agentVersions query []string false "will return only environments with on of these agent versions" // @param edgeDevice query bool false "if exists true show only edge devices, false show only regular edge endpoints. if missing, will show both types (relevant only for edge endpoints)" // @param edgeDeviceUntrusted query bool false "if true, show only untrusted endpoints, if false show only trusted (relevant only for edge devices, and if edgeDevice is true)" // @param name query string false "will return only environments(endpoints) with this name" diff --git a/api/http/handler/endpoints/endpoint_list_test.go b/api/http/handler/endpoints/endpoint_list_test.go index 86dafd692..c9c92a9fb 100644 --- a/api/http/handler/endpoints/endpoint_list_test.go +++ b/api/http/handler/endpoints/endpoint_list_test.go @@ -21,6 +21,89 @@ type endpointListTest struct { expected []portainer.EndpointID } +func Test_EndpointList_AgentVersion(t *testing.T) { + + version1Endpoint := portainer.Endpoint{ + ID: 1, + GroupID: 1, + Type: portainer.AgentOnDockerEnvironment, + Agent: struct { + Version string "example:\"1.0.0\"" + }{ + Version: "1.0.0", + }, + } + version2Endpoint := portainer.Endpoint{ID: 2, GroupID: 1, Type: portainer.AgentOnDockerEnvironment, Agent: struct { + Version string "example:\"1.0.0\"" + }{Version: "2.0.0"}} + noVersionEndpoint := portainer.Endpoint{ID: 3, Type: portainer.AgentOnDockerEnvironment, GroupID: 1} + notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1} + + handler, teardown := setup(t, []portainer.Endpoint{ + notAgentEnvironments, + version1Endpoint, + version2Endpoint, + noVersionEndpoint, + }) + + defer teardown() + + type endpointListAgentVersionTest struct { + endpointListTest + filter []string + } + + tests := []endpointListAgentVersionTest{ + { + endpointListTest{ + "should show version 1 agent endpoints and non-agent endpoints", + []portainer.EndpointID{version1Endpoint.ID, notAgentEnvironments.ID}, + }, + []string{version1Endpoint.Agent.Version}, + }, + { + endpointListTest{ + "should show version 2 endpoints and non-agent endpoints", + []portainer.EndpointID{version2Endpoint.ID, notAgentEnvironments.ID}, + }, + []string{version2Endpoint.Agent.Version}, + }, + { + endpointListTest{ + "should show version 1 and 2 endpoints and non-agent endpoints", + []portainer.EndpointID{version2Endpoint.ID, notAgentEnvironments.ID, version1Endpoint.ID}, + }, + []string{version2Endpoint.Agent.Version, version1Endpoint.Agent.Version}, + }, + } + + for _, test := range tests { + t.Run(test.title, func(t *testing.T) { + is := assert.New(t) + query := "" + for _, filter := range test.filter { + query += fmt.Sprintf("agentVersions[]=%s&", filter) + } + + req := buildEndpointListRequest(query) + + resp, err := doEndpointListRequest(req, handler, is) + is.NoError(err) + + is.Equal(len(test.expected), len(resp)) + + respIds := []portainer.EndpointID{} + + for _, endpoint := range resp { + respIds = append(respIds, endpoint.ID) + } + + is.ElementsMatch(test.expected, respIds) + }) + } + +} + func Test_endpointList_edgeDeviceFilter(t *testing.T) { trustedEdgeDevice := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} @@ -48,7 +131,7 @@ func Test_endpointList_edgeDeviceFilter(t *testing.T) { tests := []endpointListEdgeDeviceTest{ { endpointListTest: endpointListTest{ - "should show all endpoints expect of the untrusted devices", + "should show all endpoints except of the untrusted devices", []portainer.EndpointID{trustedEdgeDevice.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID, regularEndpoint.ID}, }, edgeDevice: nil, diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index e4db58616..14f6fe26b 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -55,6 +55,7 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) latestEndpointReference.Snapshots = endpoint.Snapshots latestEndpointReference.Kubernetes.Snapshots = endpoint.Kubernetes.Snapshots + latestEndpointReference.Agent.Version = endpoint.Agent.Version err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go index f66ff92ba..6432920e4 100644 --- a/api/http/handler/endpoints/endpoint_snapshots.go +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -47,6 +47,7 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request latestEndpointReference.Snapshots = endpoint.Snapshots latestEndpointReference.Kubernetes.Snapshots = endpoint.Kubernetes.Snapshots + latestEndpointReference.Agent.Version = endpoint.Agent.Version err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { diff --git a/api/http/handler/endpoints/filter.go b/api/http/handler/endpoints/filter.go index 5798f512e..0ce18764b 100644 --- a/api/http/handler/endpoints/filter.go +++ b/api/http/handler/endpoints/filter.go @@ -25,6 +25,7 @@ type EnvironmentsQuery struct { edgeDevice *bool edgeDeviceUntrusted bool name string + agentVersions []string } func parseQuery(r *http.Request) (EnvironmentsQuery, error) { @@ -60,6 +61,8 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) { return EnvironmentsQuery{}, err } + agentVersions := getArrayQueryParameter(r, "agentVersions") + name, _ := request.RetrieveQueryParameter(r, "name", true) edgeDeviceParam, _ := request.RetrieveQueryParameter(r, "edgeDevice", true) @@ -82,6 +85,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) { edgeDevice: edgeDevice, edgeDeviceUntrusted: edgeDeviceUntrusted, name: name, + agentVersions: agentVersions, }, nil } @@ -135,6 +139,12 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, query.tagIds, groups, query.tagsPartialMatch) } + if len(query.agentVersions) > 0 { + filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { + return !endpointutils.IsAgentEndpoint(&endpoint) || contains(query.agentVersions, endpoint.Agent.Version) + }) + } + return filteredEndpoints, totalAvailableEndpoints, nil } @@ -413,3 +423,13 @@ func getNumberArrayQueryParameter[T ~int](r *http.Request, parameter string) ([] return result, nil } + +func contains(strings []string, param string) bool { + for _, str := range strings { + if str == param { + return true + } + } + + return false +} diff --git a/api/http/handler/endpoints/filter_test.go b/api/http/handler/endpoints/filter_test.go index 4f96c107a..14f8cadd1 100644 --- a/api/http/handler/endpoints/filter_test.go +++ b/api/http/handler/endpoints/filter_test.go @@ -16,6 +16,64 @@ type filterTest struct { query EnvironmentsQuery } +func Test_Filter_AgentVersion(t *testing.T) { + + version1Endpoint := portainer.Endpoint{ID: 1, GroupID: 1, + Type: portainer.AgentOnDockerEnvironment, + Agent: struct { + Version string "example:\"1.0.0\"" + }{Version: "1.0.0"}} + version2Endpoint := portainer.Endpoint{ID: 2, GroupID: 1, + Type: portainer.AgentOnDockerEnvironment, + Agent: struct { + Version string "example:\"1.0.0\"" + }{Version: "2.0.0"}} + noVersionEndpoint := portainer.Endpoint{ID: 3, GroupID: 1, + Type: portainer.AgentOnDockerEnvironment, + } + notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1} + + endpoints := []portainer.Endpoint{ + version1Endpoint, + version2Endpoint, + noVersionEndpoint, + notAgentEnvironments, + } + + handler, teardown := setupFilterTest(t, endpoints) + + defer teardown() + + tests := []filterTest{ + { + "should show version 1 endpoints", + []portainer.EndpointID{version1Endpoint.ID}, + EnvironmentsQuery{ + agentVersions: []string{version1Endpoint.Agent.Version}, + types: []portainer.EndpointType{portainer.AgentOnDockerEnvironment}, + }, + }, + { + "should show version 2 endpoints", + []portainer.EndpointID{version2Endpoint.ID}, + EnvironmentsQuery{ + agentVersions: []string{version2Endpoint.Agent.Version}, + types: []portainer.EndpointType{portainer.AgentOnDockerEnvironment}, + }, + }, + { + "should show version 1 and 2 endpoints", + []portainer.EndpointID{version2Endpoint.ID, version1Endpoint.ID}, + EnvironmentsQuery{ + agentVersions: []string{version2Endpoint.Agent.Version, version1Endpoint.Agent.Version}, + types: []portainer.EndpointType{portainer.AgentOnDockerEnvironment}, + }, + }, + } + + runTests(tests, t, handler, endpoints) +} + func Test_Filter_edgeDeviceFilter(t *testing.T) { trustedEdgeDevice := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 764236a61..f51e7c67b 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -67,6 +67,9 @@ func NewHandler(bouncer requestBouncer, demoService *demo.Service) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost) h.Handle("/endpoints", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet) + h.Handle("/endpoints/agent_versions", + bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet) + h.Handle("/endpoints/{id}", bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet) h.Handle("/endpoints/{id}", diff --git a/api/internal/set/set.go b/api/internal/set/set.go new file mode 100644 index 000000000..5b7d7d770 --- /dev/null +++ b/api/internal/set/set.go @@ -0,0 +1,40 @@ +package set + +type SetKey interface { + ~int | ~string +} + +type Set[T SetKey] map[T]bool + +func (s Set[T]) Add(key T) { + s[key] = true +} + +func (s Set[T]) Contains(key T) bool { + _, ok := s[key] + return ok +} + +func (s Set[T]) Remove(key T) { + delete(s, key) +} + +func (s Set[T]) Len() int { + return len(s) +} + +func (s Set[T]) IsEmpty() bool { + return len(s) == 0 +} + +func (s Set[T]) Keys() []T { + keys := make([]T, s.Len()) + + i := 0 + for k := range s { + keys[i] = k + i++ + } + + return keys +} diff --git a/api/internal/snapshot/snapshot.go b/api/internal/snapshot/snapshot.go index e20cf21ef..f4572d7cd 100644 --- a/api/internal/snapshot/snapshot.go +++ b/api/internal/snapshot/snapshot.go @@ -2,11 +2,14 @@ package snapshot import ( "context" + "crypto/tls" "errors" "log" "time" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/agent" + "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/dataservices" ) @@ -87,6 +90,24 @@ func SupportDirectSnapshot(endpoint *portainer.Endpoint) bool { // SnapshotEndpoint will create a snapshot of the environment(endpoint) based on the environment(endpoint) type. // If the snapshot is a success, it will be associated to the environment(endpoint). func (service *Service) SnapshotEndpoint(endpoint *portainer.Endpoint) error { + if endpoint.Type == portainer.AgentOnDockerEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment { + var err error + var tlsConfig *tls.Config + if endpoint.TLSConfig.TLS { + tlsConfig, err = crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify) + if err != nil { + return err + } + } + + _, version, err := agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig) + if err != nil { + return err + } + + endpoint.Agent.Version = version + } + switch endpoint.Type { case portainer.AzureEnvironment: return nil @@ -175,6 +196,7 @@ func (service *Service) snapshotEndpoints() error { latestEndpointReference.Snapshots = endpoint.Snapshots latestEndpointReference.Kubernetes.Snapshots = endpoint.Kubernetes.Snapshots + latestEndpointReference.Agent.Version = endpoint.Agent.Version err = service.dataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { diff --git a/api/portainer.go b/api/portainer.go index 6f6d04b30..13333c45b 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -359,6 +359,10 @@ type ( CommandInterval int `json:"CommandInterval" example:"60"` } + Agent struct { + Version string `example:"1.0.0"` + } + // Deprecated fields // Deprecated in DBVersion == 4 TLS bool `json:"TLS,omitempty"` diff --git a/app/portainer/environments/environment.service/create.ts b/app/portainer/environments/environment.service/create.ts index 87df20c9e..cc155ab3e 100644 --- a/app/portainer/environments/environment.service/create.ts +++ b/app/portainer/environments/environment.service/create.ts @@ -166,6 +166,7 @@ interface CreateEdgeAgentEnvironment { meta?: EnvironmentMetadata; pollFrequency: number; gpus?: Gpu[]; + isEdgeDevice?: boolean; } export function createEdgeAgentEnvironment({ @@ -173,18 +174,20 @@ export function createEdgeAgentEnvironment({ portainerUrl, meta = { tagIds: [] }, gpus = [], + isEdgeDevice, }: CreateEdgeAgentEnvironment) { return createEnvironment( name, EnvironmentCreationTypes.EdgeAgentEnvironment, { url: portainerUrl, - ...meta, tls: { skipVerify: true, skipClientVerify: true, }, gpus, + isEdgeDevice, + ...meta, } ); } diff --git a/app/portainer/environments/environment.service/index.ts b/app/portainer/environments/environment.service/index.ts index 0bef44712..0732e371e 100644 --- a/app/portainer/environments/environment.service/index.ts +++ b/app/portainer/environments/environment.service/index.ts @@ -26,6 +26,7 @@ export interface EnvironmentsQueryParams { edgeDeviceUntrusted?: boolean; provisioned?: boolean; name?: string; + agentVersions?: string[]; } export interface GetEnvironmentsOptions { @@ -72,6 +73,17 @@ export async function getEnvironments( } } +export async function getAgentVersions() { + try { + const response = await axios.get( + buildUrl(undefined, 'agent_versions') + ); + return response.data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} + export async function getEndpoint(id: EnvironmentId) { try { const { data: endpoint } = await axios.get(buildUrl(id)); diff --git a/app/portainer/environments/queries/useAgentVersionsList.ts b/app/portainer/environments/queries/useAgentVersionsList.ts new file mode 100644 index 000000000..c1a3e7dec --- /dev/null +++ b/app/portainer/environments/queries/useAgentVersionsList.ts @@ -0,0 +1,7 @@ +import { useQuery } from 'react-query'; + +import { getAgentVersions } from '../environment.service'; + +export function useAgentVersionsList() { + return useQuery(['environments', 'agentVersions'], () => getAgentVersions()); +} diff --git a/app/portainer/environments/types.ts b/app/portainer/environments/types.ts index 3c6ec897d..fa0c33f39 100644 --- a/app/portainer/environments/types.ts +++ b/app/portainer/environments/types.ts @@ -90,6 +90,7 @@ export interface EnvironmentSecuritySettings { } export type Environment = { + Agent: { Version: string }; Id: EnvironmentId; Type: EnvironmentType; TagIds: TagId[]; @@ -112,6 +113,7 @@ export type Environment = { SecuritySettings: EnvironmentSecuritySettings; Gpus: { name: string; value: string }[]; }; + /** * TS reference of endpoint_create.go#EndpointCreationType iota */ diff --git a/app/portainer/environments/utils.ts b/app/portainer/environments/utils.ts index 50efaaee5..66bc2afe5 100644 --- a/app/portainer/environments/utils.ts +++ b/app/portainer/environments/utils.ts @@ -25,6 +25,15 @@ export function isKubernetesEnvironment(envType: EnvironmentType) { return getPlatformType(envType) === PlatformType.Kubernetes; } +export function isAgentEnvironment(envType: EnvironmentType) { + return ( + isEdgeEnvironment(envType) || + [EnvironmentType.AgentOnDocker, EnvironmentType.AgentOnKubernetes].includes( + envType + ) + ); +} + export function isEdgeEnvironment(envType: EnvironmentType) { return [ EnvironmentType.EdgeAgentOnDocker, diff --git a/app/portainer/home/EnvironmentList/EnvironmentItem/AgentVersionTag.tsx b/app/portainer/home/EnvironmentList/EnvironmentItem/AgentVersionTag.tsx new file mode 100644 index 000000000..310c25667 --- /dev/null +++ b/app/portainer/home/EnvironmentList/EnvironmentItem/AgentVersionTag.tsx @@ -0,0 +1,29 @@ +import { Zap } from 'react-feather'; + +import { EnvironmentType } from '@/portainer/environments/types'; +import { + isAgentEnvironment, + isEdgeEnvironment, +} from '@/portainer/environments/utils'; + +interface Props { + type: EnvironmentType; + version: string; +} + +export function AgentVersionTag({ type, version }: Props) { + if (!isAgentEnvironment(type)) { + return null; + } + + return ( + + + + + {isEdgeEnvironment(type) ? 'Edge Agent' : 'Agent'} + + {version} + + ); +} diff --git a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStats.tsx b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStats.tsx index d4dc3fcc8..1391e8c86 100644 --- a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStats.tsx +++ b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStats.tsx @@ -15,6 +15,8 @@ export function EnvironmentStats({ environment }: Props) { return ( ); case PlatformType.Docker: @@ -22,6 +24,7 @@ export function EnvironmentStats({ environment }: Props) { ); default: diff --git a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStatsDocker.tsx b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStatsDocker.tsx index 34e68e910..d0e768b23 100644 --- a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStatsDocker.tsx +++ b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStatsDocker.tsx @@ -1,19 +1,23 @@ -import { Zap } from 'react-feather'; - import { DockerSnapshot, EnvironmentType, } from '@/portainer/environments/types'; import { addPlural } from '@/portainer/helpers/strings'; +import { AgentVersionTag } from './AgentVersionTag'; import { Stat } from './EnvironmentStatsItem'; interface Props { snapshots: DockerSnapshot[]; type: EnvironmentType; + agentVersion: string; } -export function EnvironmentStatsDocker({ snapshots = [], type }: Props) { +export function EnvironmentStatsDocker({ + snapshots = [], + type, + agentVersion, +}: Props) { if (snapshots.length === 0) { return (
@@ -60,15 +64,9 @@ export function EnvironmentStatsDocker({ snapshots = [], type }: Props) { - {snapshot.Swarm ? 'Swarm' : 'Standalone'} - {snapshot.DockerVersion} - {type === EnvironmentType.AgentOnDocker && ( - - +{' '} - - )} + + {snapshot.Swarm ? 'Swarm' : 'Standalone'} {snapshot.DockerVersion} + {snapshot.Swarm && ( )} +
); diff --git a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStatsKubernetes.tsx b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStatsKubernetes.tsx index 88f2ae1c3..8881eb6e3 100644 --- a/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStatsKubernetes.tsx +++ b/app/portainer/home/EnvironmentList/EnvironmentItem/EnvironmentStatsKubernetes.tsx @@ -1,14 +1,24 @@ -import { KubernetesSnapshot } from '@/portainer/environments/types'; +import { + EnvironmentType, + KubernetesSnapshot, +} from '@/portainer/environments/types'; import { humanize } from '@/portainer/filters/filters'; import { addPlural } from '@/portainer/helpers/strings'; +import { AgentVersionTag } from './AgentVersionTag'; import { Stat } from './EnvironmentStatsItem'; interface Props { snapshots?: KubernetesSnapshot[]; + type: EnvironmentType; + agentVersion: string; } -export function EnvironmentStatsKubernetes({ snapshots = [] }: Props) { +export function EnvironmentStatsKubernetes({ + snapshots = [], + type, + agentVersion, +}: Props) { if (snapshots.length === 0) { return (
@@ -38,6 +48,7 @@ export function EnvironmentStatsKubernetes({ snapshots = [] }: Props) { icon="hard-drive" featherIcon /> +
); diff --git a/app/portainer/home/EnvironmentList/EnvironmentList.tsx b/app/portainer/home/EnvironmentList/EnvironmentList.tsx index 4ef8e02d6..69248dcfa 100644 --- a/app/portainer/home/EnvironmentList/EnvironmentList.tsx +++ b/app/portainer/home/EnvironmentList/EnvironmentList.tsx @@ -1,12 +1,15 @@ import { ReactNode, useEffect, useState } from 'react'; import clsx from 'clsx'; import { RefreshCcw } from 'react-feather'; +import _ from 'lodash'; import { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState'; import { Environment, EnvironmentType, EnvironmentStatus, + PlatformType, + EdgeTypes, } from '@/portainer/environments/types'; import { EnvironmentGroupId } from '@/portainer/environment-groups/types'; import { useIsAdmin } from '@/portainer/hooks/useUser'; @@ -22,6 +25,8 @@ import { import { useGroups } from '@/portainer/environment-groups/queries'; import { useTags } from '@/portainer/tags/queries'; import { Filter } from '@/portainer/home/types'; +import { useAgentVersionsList } from '@/portainer/environments/queries/useAgentVersionsList'; +import { EnvironmentsQueryParams } from '@/portainer/environments/environment.service'; import { TableFooter } from '@@/datatables/TableFooter'; import { TableActions, TableContainer, TableTitle } from '@@/datatables'; @@ -35,54 +40,49 @@ import { PaginationControls } from '@@/PaginationControls'; import { EnvironmentItem } from './EnvironmentItem'; import { KubeconfigButton } from './KubeconfigButton'; -import styles from './EnvironmentList.module.css'; import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel'; +import styles from './EnvironmentList.module.css'; interface Props { onClickItem(environment: Environment): void; onRefresh(): void; } -const PlatformOptions = [ - { value: EnvironmentType.Docker, label: 'Docker' }, - { value: EnvironmentType.Azure, label: 'Azure' }, - { value: EnvironmentType.KubernetesLocal, label: 'Kubernetes' }, -]; - const status = [ { value: EnvironmentStatus.Up, label: 'Up' }, { value: EnvironmentStatus.Down, label: 'Down' }, ]; -const SortByOptions = [ +const sortByOptions = [ { value: 1, label: 'Name' }, { value: 2, label: 'Group' }, { value: 3, label: 'Status' }, ]; +enum ConnectionType { + API, + Agent, + EdgeAgent, + EdgeDevice, +} + const storageKey = 'home_endpoints'; -const allEnvironmentType = [ - EnvironmentType.Docker, - EnvironmentType.AgentOnDocker, - EnvironmentType.Azure, - EnvironmentType.EdgeAgentOnDocker, - EnvironmentType.KubernetesLocal, - EnvironmentType.AgentOnKubernetes, - EnvironmentType.EdgeAgentOnKubernetes, -]; export function EnvironmentList({ onClickItem, onRefresh }: Props) { const isAdmin = useIsAdmin(); - const [platformType, setPlatformType] = useHomePageFilter( - 'platformType', - allEnvironmentType - ); + const [platformTypes, setPlatformTypes] = useHomePageFilter< + Filter[] + >('platformType', []); const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey); const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey); const [page, setPage] = useState(1); const debouncedTextFilter = useDebounce(searchBarValue); + const [connectionTypes, setConnectionTypes] = useHomePageFilter< + Filter[] + >('connectionTypes', []); + const [statusFilter, setStatusFilter] = useHomePageFilter< EnvironmentStatus[] >('status', []); @@ -101,10 +101,6 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { false ); - const [platformState, setPlatformState] = useHomePageFilter( - 'type_state', - [] - ); const [statusState, setStatusState] = useHomePageFilter( 'status_state', [] @@ -115,31 +111,46 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { [] ); const [sortByState, setSortByState] = useHomePageFilter( - 'sortby_state', + 'sort_by_state', undefined ); + const [agentVersions, setAgentVersions] = useHomePageFilter[]>( + 'agentVersions', + [] + ); const groupsQuery = useGroups(); + const environmentsQueryParams: EnvironmentsQueryParams = { + types: getTypes( + platformTypes.map((p) => p.value), + connectionTypes.map((p) => p.value) + ), + search: debouncedTextFilter, + status: statusFilter, + tagIds: tagFilter?.length ? tagFilter : undefined, + groupIds: groupFilter, + edgeDevice: getEdgeDeviceFilter(connectionTypes.map((p) => p.value)), + tagsPartialMatch: true, + agentVersions: agentVersions.map((a) => a.value), + }; + + const tagsQuery = useTags(); + const { isLoading, environments, totalCount, totalAvailable } = useEnvironmentList( { page, pageLimit, - types: platformType, - search: debouncedTextFilter, - status: statusFilter, - tagIds: tagFilter?.length ? tagFilter : undefined, - groupIds: groupFilter, sort: sortByFilter, order: sortByDescending ? 'desc' : 'asc', - provisioned: true, - edgeDevice: false, - tagsPartialMatch: true, + ...environmentsQueryParams, }, refetchIfAnyOffline ); + const agentVersionsQuery = useAgentVersionsList(); + useEffect(() => { setPage(1); }, [searchBarValue]); @@ -152,8 +163,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { label, })); - const alltags = useTags(); - const tagOptions = [...(alltags.tags || [])]; + const tagOptions = [...(tagsQuery.tags || [])]; const uniqueTag = [ ...new Map(tagOptions.map((item) => [item.ID, item])).values(), ].map(({ ID: value, Name: label }) => ({ @@ -161,39 +171,231 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { label, })); - function platformOnChange(filterOptions: Filter[]) { - setPlatformState(filterOptions); - const dockerBaseType = EnvironmentType.Docker; - const kubernetesBaseType = EnvironmentType.KubernetesLocal; - const dockerRelateType = [ - EnvironmentType.AgentOnDocker, - EnvironmentType.EdgeAgentOnDocker, - ]; - const kubernetesRelateType = [ - EnvironmentType.AgentOnKubernetes, - EnvironmentType.EdgeAgentOnKubernetes, - ]; + const connectionTypeOptions = getConnectionTypeOptions(platformTypes); + const platformTypeOptions = getPlatformTypeOptions(connectionTypes); - if (filterOptions.length === 0) { - setPlatformType(allEnvironmentType); - } else { - let finalFilterEnvironment = filterOptions.map( - (filterOption) => filterOption.value - ); - if (finalFilterEnvironment.includes(dockerBaseType)) { - finalFilterEnvironment = [ - ...finalFilterEnvironment, - ...dockerRelateType, - ]; - } - if (finalFilterEnvironment.includes(kubernetesBaseType)) { - finalFilterEnvironment = [ - ...finalFilterEnvironment, - ...kubernetesRelateType, - ]; - } - setPlatformType(finalFilterEnvironment); + return ( + <> + {totalAvailable === 0 && } +
+
+ + + + +
+ Click on an environment to manage +
+
+
+ {isAdmin && ( + + )} +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + filterOptions={ + agentVersionsQuery.data?.map((v) => ({ + label: v, + value: v, + })) || [] + } + onChange={setAgentVersions} + placeHolder="Agent Version" + value={agentVersions} + /> +
+ +
+ +
+
+
+ {renderItems( + isLoading, + totalCount, + environments.map((env) => ( + g.Id === env.GroupId)?.Name + } + onClick={onClickItem} + /> + )) + )} +
+ + + + +
+
+
+ + ); + + function getEdgeDeviceFilter(connectionTypes: ConnectionType[]) { + // show both types of edge agent if both are selected or if no connection type is selected + if ( + connectionTypes.length === 0 || + (connectionTypes.includes(ConnectionType.EdgeAgent) && + connectionTypes.includes(ConnectionType.EdgeDevice)) + ) { + return undefined; } + + return connectionTypes.includes(ConnectionType.EdgeDevice); + } + + function getTypes( + platformTypes: PlatformType[], + connectionTypes: ConnectionType[] + ) { + if (platformTypes.length === 0 && connectionTypes.length === 0) { + return []; + } + + const typesByPlatform = { + [PlatformType.Docker]: [ + EnvironmentType.Docker, + EnvironmentType.AgentOnDocker, + EnvironmentType.EdgeAgentOnDocker, + ], + [PlatformType.Azure]: [EnvironmentType.Azure], + [PlatformType.Kubernetes]: [ + EnvironmentType.KubernetesLocal, + EnvironmentType.AgentOnKubernetes, + EnvironmentType.EdgeAgentOnKubernetes, + ], + }; + + const typesByConnection = { + [ConnectionType.API]: [ + EnvironmentType.Azure, + EnvironmentType.KubernetesLocal, + EnvironmentType.Docker, + ], + [ConnectionType.Agent]: [ + EnvironmentType.AgentOnDocker, + EnvironmentType.AgentOnKubernetes, + ], + [ConnectionType.EdgeAgent]: EdgeTypes, + [ConnectionType.EdgeDevice]: EdgeTypes, + }; + + const selectedTypesByPlatform = platformTypes.flatMap( + (platformType) => typesByPlatform[platformType] + ); + const selectedTypesByConnection = connectionTypes.flatMap( + (connectionType) => typesByConnection[connectionType] + ); + + if (selectedTypesByPlatform.length === 0) { + return selectedTypesByConnection; + } + + if (selectedTypesByConnection.length === 0) { + return selectedTypesByPlatform; + } + + return _.intersection(selectedTypesByConnection, selectedTypesByPlatform); } function statusOnChange(filterOptions: Filter[]) { @@ -245,8 +447,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { } function clearFilter() { - setPlatformState([]); - setPlatformType(allEnvironmentType); + setPlatformTypes([]); setStatusState([]); setStatusFilter([]); setTagState([]); @@ -267,149 +468,67 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) { } } - function sortOndescending() { + function sortOnDescending() { setSortByDescending(!sortByDescending); } +} - return ( - <> - {totalAvailable === 0 && } -
-
- - +function getConnectionTypeOptions(platformTypes: Filter[]) { + const platformTypeConnectionType = { + [PlatformType.Docker]: [ + ConnectionType.API, + ConnectionType.Agent, + ConnectionType.EdgeAgent, + ConnectionType.EdgeDevice, + ], + [PlatformType.Azure]: [ConnectionType.API], + [PlatformType.Kubernetes]: [ + ConnectionType.Agent, + ConnectionType.EdgeAgent, + ConnectionType.EdgeDevice, + ], + }; - -
- Click on an environment to manage -
-
-
- {isAdmin && ( - - )} -
-
- -
-
- -
-
-
-
-
- -
-
- -
-
- -
-
- -
- -
- -
-
-
- {renderItems( - isLoading, - totalCount, - environments.map((env) => ( - g.Id === env.GroupId)?.Name - } - onClick={onClickItem} - /> - )) - )} -
+ const connectionTypesDefaultOptions = [ + { value: ConnectionType.API, label: 'API' }, + { value: ConnectionType.Agent, label: 'Agent' }, + { value: ConnectionType.EdgeAgent, label: 'Edge Agent' }, + { value: ConnectionType.EdgeDevice, label: 'Edge Device' }, + ]; - - - -
-
-
- + if (platformTypes.length === 0) { + return connectionTypesDefaultOptions; + } + + return _.compact( + _.intersection( + ...platformTypes.map((p) => platformTypeConnectionType[p.value]) + ).map((c) => connectionTypesDefaultOptions.find((o) => o.value === c)) + ); +} + +function getPlatformTypeOptions(connectionTypes: Filter[]) { + const platformDefaultOptions = [ + { value: PlatformType.Docker, label: 'Docker' }, + { value: PlatformType.Azure, label: 'Azure' }, + { value: PlatformType.Kubernetes, label: 'Kubernetes' }, + ]; + + if (connectionTypes.length === 0) { + return platformDefaultOptions; + } + + const connectionTypePlatformType = { + [ConnectionType.API]: [PlatformType.Docker, PlatformType.Azure], + [ConnectionType.Agent]: [PlatformType.Docker, PlatformType.Kubernetes], + [ConnectionType.EdgeAgent]: [PlatformType.Kubernetes, PlatformType.Docker], + [ConnectionType.EdgeDevice]: [PlatformType.Docker, PlatformType.Kubernetes], + }; + + return _.compact( + _.intersection( + ...connectionTypes.map((p) => connectionTypePlatformType[p.value]) + ).map((c) => platformDefaultOptions.find((o) => o.value === c)) ); } diff --git a/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx index 2c1332ca3..150e5bed4 100644 --- a/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx +++ b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx @@ -2,14 +2,15 @@ import { useState } from 'react'; import { Download } from 'react-feather'; import { Environment } from '@/portainer/environments/types'; -import { Query } from '@/portainer/environments/queries/useEnvironmentList'; import { isKubernetesEnvironment } from '@/portainer/environments/utils'; import { trackEvent } from '@/angulartics.matomo/analytics-services'; +import { Query } from '@/portainer/environments/queries/useEnvironmentList'; import { Button } from '@@/buttons'; import styles from './KubeconfigButton.module.css'; import { KubeconfigPrompt } from './KubeconfigPrompt'; + import '@reach/dialog/styles.css'; export interface Props { diff --git a/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx index 22fd9c13b..178a817f7 100644 --- a/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx +++ b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx @@ -4,10 +4,12 @@ import { DialogOverlay } from '@reach/dialog'; import * as kcService from '@/kubernetes/services/kubeconfig.service'; import * as notifications from '@/portainer/services/notifications'; import { EnvironmentType } from '@/portainer/environments/types'; -import { EnvironmentsQueryParams } from '@/portainer/environments/environment.service/index'; import { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState'; -import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList'; import { usePublicSettings } from '@/portainer/settings/queries'; +import { + Query, + useEnvironmentList, +} from '@/portainer/environments/queries/useEnvironmentList'; import { PaginationControls } from '@@/PaginationControls'; import { Checkbox } from '@@/form-components/Checkbox'; @@ -18,7 +20,7 @@ import styles from './KubeconfigPrompt.module.css'; import '@reach/dialog/styles.css'; export interface KubeconfigPromptProps { - envQueryParams: EnvironmentsQueryParams; + envQueryParams: Query; onClose: () => void; } const storageKey = 'home_endpoints'; diff --git a/app/portainer/home/HomepageFilter.tsx b/app/portainer/home/HomepageFilter.tsx index 57d12b680..0b5811104 100644 --- a/app/portainer/home/HomepageFilter.tsx +++ b/app/portainer/home/HomepageFilter.tsx @@ -5,14 +5,14 @@ import { Filter } from '@/portainer/home/types'; import { Select } from '@@/form-components/ReactSelect'; -interface Props { - filterOptions: Filter[]; - onChange: (filterOptions: Filter[]) => void; +interface Props { + filterOptions?: Filter[]; + onChange: (filterOptions: Filter[]) => void; placeHolder: string; - value: Filter[]; + value: Filter[]; } -function Option(props: OptionProps) { +function Option(props: OptionProps, true>) { const { isSelected, label } = props; return (
@@ -27,12 +27,12 @@ function Option(props: OptionProps) { ); } -export function HomepageFilter({ - filterOptions, +export function HomepageFilter({ + filterOptions = [], onChange, placeHolder, value, -}: Props) { +}: Props) { return (