1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-22 06:49:40 +02:00

fix(kubernetes): events api to call the backend [R8S-243] (#563)

This commit is contained in:
Cara Ryan 2025-05-27 13:55:31 +12:00 committed by GitHub
parent 32ef208278
commit 07dfd981a2
26 changed files with 750 additions and 217 deletions

View file

@ -30,8 +30,8 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.") log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.")
return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err) return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err)
} }
pcli.IsKubeAdmin = cli.IsKubeAdmin pcli.SetIsKubeAdmin(cli.GetIsKubeAdmin())
pcli.NonAdminNamespaces = cli.NonAdminNamespaces pcli.SetClientNonAdminNamespaces(cli.GetClientNonAdminNamespaces())
return pcli, nil return pcli, nil
} }

View file

@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWrite
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr) return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr)
} }
if !cli.IsKubeAdmin { if !cli.GetIsKubeAdmin() {
log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.") log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.")
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil) return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil)
} }

View file

@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr) return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr)
} }
if !cli.IsKubeAdmin { if !cli.GetIsKubeAdmin() {
log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.") log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.")
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil) return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil)
} }

View file

@ -0,0 +1,102 @@
package kubernetes
import (
"net/http"
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"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
)
// @id getKubernetesEventsForNamespace
// @summary Gets kubernetes events for namespace
// @description Get events by optional query param resourceId for a given namespace.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "The namespace name the events are associated to"
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
// @success 200 {object} models.Event[] "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 500 "Server error occurred while attempting to retrieve the events within the specified namespace."
// @router /kubernetes/{id}/namespaces/{namespace}/events [get]
func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesEvents").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable")
return httperror.BadRequest("Unable to retrieve namespace identifier route variable", err)
}
resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true)
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter")
return httperror.BadRequest("Unable to retrieve resourceId query parameter", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user")
return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
events, err := cli.GetEvents(namespace, resourceId)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API")
return httperror.Forbidden("Unauthorized access to the Kubernetes API", err)
}
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events")
return httperror.InternalServerError("Unable to retrieve events", err)
}
return response.JSON(w, events)
}
// @id getAllKubernetesEvents
// @summary Gets kubernetes events
// @description Get events by query param resourceId
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
// @success 200 {object} models.Event[] "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 500 "Server error occurred while attempting to retrieve the events."
// @router /kubernetes/{id}/events [get]
func (handler *Handler) getAllKubernetesEvents(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true)
if err != nil {
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter")
return httperror.BadRequest("Unable to retrieve resourceId query parameter", err)
}
cli, httpErr := handler.getProxyKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user")
return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
events, err := cli.GetEvents("", resourceId)
if err != nil {
if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API")
return httperror.Forbidden("Unauthorized access to the Kubernetes API", err)
}
log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events")
return httperror.InternalServerError("Unable to retrieve events", err)
}
return response.JSON(w, events)
}

View file

@ -0,0 +1,60 @@
package kubernetes
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
kubeClient "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/stretchr/testify/assert"
)
// Currently this test just tests the HTTP Handler is setup correctly, in the future we should move the ClientFactory to a mock in order
// test the logic in event.go
func TestGetKubernetesEvents(t *testing.T) {
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
err := store.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Type: portainer.AgentOnKubernetesEnvironment,
},
)
is.NoError(err, "error creating environment")
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "error creating a user")
jwtService, err := jwt.NewService("1h", store)
is.NoError(err, "Error initiating jwt service")
tk, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: 1, Username: "admin", Role: portainer.AdministratorRole})
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
cli := testhelpers.NewKubernetesClient()
factory, _ := kubeClient.NewClientFactory(nil, nil, store, "", "", "")
authorizationService := authorization.NewService(store)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService,
factory, cli)
is.NotNil(handler, "Handler should not fail")
req := httptest.NewRequest(http.MethodGet, "/kubernetes/1/events?resourceId=8", nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
}

View file

@ -58,6 +58,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet) endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet) endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost) endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/events", httperror.LoggerHandler(h.getAllKubernetesEvents)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet) endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost) endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet) endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
@ -110,6 +111,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// to keep it simple, we've decided to leave it like this. // to keep it simple, we've decided to leave it like this.
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter() namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet) namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut) namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet) namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut) namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
@ -133,7 +135,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient // getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient
// from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using // from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using
// admin permissions. If you're unsure which one to use, use this. // admin permissions. If you're unsure which one to use, use this.
func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) { func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *httperror.HandlerError) {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err) return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err)
@ -253,7 +255,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return return
} }
serverURL.Scheme = "https" serverURL.Scheme = "https"
serverURL.Host = "localhost" + handler.KubernetesClientFactory.AddrHTTPS serverURL.Host = "localhost" + handler.KubernetesClientFactory.GetAddrHTTPS()
config.Clusters[0].Cluster.Server = serverURL.String() config.Clusters[0].Cluster.Server = serverURL.String()
yaml, err := cli.GenerateYAML(config) yaml, err := cli.GenerateYAML(config)

View file

@ -0,0 +1,25 @@
package kubernetes
import "time"
type K8sEvent struct {
Type string `json:"type"`
Name string `json:"name"`
Reason string `json:"reason"`
Message string `json:"message"`
Namespace string `json:"namespace"`
EventTime time.Time `json:"eventTime"`
Kind string `json:"kind,omitempty"`
Count int32 `json:"count"`
FirstTimestamp *time.Time `json:"firstTimestamp,omitempty"`
LastTimestamp *time.Time `json:"lastTimestamp,omitempty"`
UID string `json:"uid"`
InvolvedObjectKind K8sEventInvolvedObject `json:"involvedObject"`
}
type K8sEventInvolvedObject struct {
Kind string `json:"kind,omitempty"`
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
}

View file

@ -0,0 +1,19 @@
package testhelpers
import (
portainer "github.com/portainer/portainer/api"
models "github.com/portainer/portainer/api/http/models/kubernetes"
)
type testKubeClient struct {
portainer.KubeClient
}
func NewKubernetesClient() portainer.KubeClient {
return &testKubeClient{}
}
// Event
func (kcl *testKubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
return nil, nil
}

View file

@ -143,3 +143,23 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestri
return nonAdminNamespaces, nil return nonAdminNamespaces, nil
} }
// GetIsKubeAdmin retrieves true if client is admin
func (client *KubeClient) GetIsKubeAdmin() bool {
return client.IsKubeAdmin
}
// UpdateIsKubeAdmin sets whether the kube client is admin
func (client *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) {
client.IsKubeAdmin = isKubeAdmin
}
// GetClientNonAdminNamespaces retrieves non-admin namespaces
func (client *KubeClient) GetClientNonAdminNamespaces() []string {
return client.NonAdminNamespaces
}
// UpdateClientNonAdminNamespaces sets the client non admin namespace list
func (client *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) {
client.NonAdminNamespaces = nonAdminNamespaces
}

View file

@ -82,6 +82,10 @@ func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID)
factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID))) factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID)))
} }
func (factory *ClientFactory) GetAddrHTTPS() string {
return factory.AddrHTTPS
}
// GetPrivilegedKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. // GetPrivilegedKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found.
// If no client is registered, it will create a new client, register it, and returns it. // If no client is registered, it will create a new client, register it, and returns it.
func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) {

View file

@ -0,0 +1,93 @@
package cli
import (
"context"
models "github.com/portainer/portainer/api/http/models/kubernetes"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetEvents gets all the Events for a given namespace and resource
// If the user is a kube admin, it returns all events in the namespace
// Otherwise, it returns only the events in the non-admin namespaces
func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
if kcl.IsKubeAdmin {
return kcl.fetchAllEvents(namespace, resourceId)
}
return kcl.fetchEventsForNonAdmin(namespace, resourceId)
}
// fetchEventsForNonAdmin returns all events in the given namespace and resource
// It returns only the events in the non-admin namespaces
func (kcl *KubeClient) fetchEventsForNonAdmin(namespace string, resourceId string) ([]models.K8sEvent, error) {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
events, err := kcl.fetchAllEvents(namespace, resourceId)
if err != nil {
return nil, err
}
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
results := make([]models.K8sEvent, 0)
for _, event := range events {
if _, ok := nonAdminNamespaceSet[event.Namespace]; ok {
results = append(results, event)
}
}
return results, nil
}
// fetchEventsForNonAdmin returns all events in the given namespace and resource
// It returns all events in the namespace and resource
func (kcl *KubeClient) fetchAllEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
options := metav1.ListOptions{}
if resourceId != "" {
options.FieldSelector = "involvedObject.uid=" + resourceId
}
list, err := kcl.cli.CoreV1().Events(namespace).List(context.TODO(), options)
if err != nil {
return nil, err
}
results := make([]models.K8sEvent, 0)
for _, event := range list.Items {
results = append(results, parseEvent(&event))
}
return results, nil
}
func parseEvent(event *corev1.Event) models.K8sEvent {
result := models.K8sEvent{
Type: event.Type,
Name: event.Name,
Message: event.Message,
Reason: event.Reason,
Namespace: event.Namespace,
EventTime: event.EventTime.UTC(),
Kind: event.Kind,
Count: event.Count,
UID: string(event.ObjectMeta.GetUID()),
InvolvedObjectKind: models.K8sEventInvolvedObject{
Kind: event.InvolvedObject.Kind,
UID: string(event.InvolvedObject.UID),
Name: event.InvolvedObject.Name,
Namespace: event.InvolvedObject.Namespace,
},
}
if !event.LastTimestamp.Time.IsZero() {
result.LastTimestamp = &event.LastTimestamp.Time
}
if !event.FirstTimestamp.Time.IsZero() {
result.FirstTimestamp = &event.FirstTimestamp.Time
}
return result
}

View file

@ -0,0 +1,108 @@
package cli
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
// TestGetEvents tests the GetEvents method
// It creates a fake Kubernetes client and passes it to the GetEvents method
// It then logs the fetched events and validated the data returned
func TestGetEvents(t *testing.T) {
t.Run("can get events for resource id when admin", func(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
IsKubeAdmin: true,
}
event := corev1.Event{
InvolvedObject: corev1.ObjectReference{UID: "resourceId"},
Action: "something",
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "myEvent"},
EventTime: metav1.NowMicro(),
Type: "warning",
Message: "This event has a very serious warning",
}
_, err := kcl.cli.CoreV1().Events("default").Create(context.TODO(), &event, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create Event: %v", err)
}
events, err := kcl.GetEvents("default", "resourceId")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Events: %v", events)
require.Equal(t, 1, len(events), "Expected to return 1 event")
assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created")
assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created")
assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created")
})
t.Run("can get kubernetes events for non admin namespace when non admin", func(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
IsKubeAdmin: false,
NonAdminNamespaces: []string{"nonAdmin"},
}
event := corev1.Event{
InvolvedObject: corev1.ObjectReference{UID: "resourceId"},
Action: "something",
ObjectMeta: metav1.ObjectMeta{Namespace: "nonAdmin", Name: "myEvent"},
EventTime: metav1.NowMicro(),
Type: "warning",
Message: "This event has a very serious warning",
}
_, err := kcl.cli.CoreV1().Events("nonAdmin").Create(context.TODO(), &event, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create Event: %v", err)
}
events, err := kcl.GetEvents("nonAdmin", "resourceId")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Events: %v", events)
require.Equal(t, 1, len(events), "Expected to return 1 event")
assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created")
assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created")
assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created")
})
t.Run("cannot get kubernetes events for admin namespace when non admin", func(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
IsKubeAdmin: false,
NonAdminNamespaces: []string{"nonAdmin"},
}
event := corev1.Event{
InvolvedObject: corev1.ObjectReference{UID: "resourceId"},
Action: "something",
ObjectMeta: metav1.ObjectMeta{Namespace: "admin", Name: "myEvent"},
EventTime: metav1.NowMicro(),
Type: "warning",
Message: "This event has a very serious warning",
}
_, err := kcl.cli.CoreV1().Events("admin").Create(context.TODO(), &event, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create Event: %v", err)
}
events, err := kcl.GetEvents("admin", "resourceId")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Events: %v", events)
assert.Equal(t, 0, len(events), "Expected to return 0 events")
})
}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"net/http"
"time" "time"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
@ -13,6 +14,7 @@ import (
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
models "github.com/portainer/portainer/api/http/models/kubernetes" models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/pkg/featureflags" "github.com/portainer/portainer/pkg/featureflags"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -1531,56 +1533,127 @@ type (
// KubeClient represents a service used to query a Kubernetes environment(endpoint) // KubeClient represents a service used to query a Kubernetes environment(endpoint)
KubeClient interface { KubeClient interface {
ServerVersion() (*version.Info, error) // Access
GetIsKubeAdmin() bool
SetIsKubeAdmin(isKubeAdmin bool)
GetClientNonAdminNamespaces() []string
SetClientNonAdminNamespaces([]string)
NamespaceAccessPoliciesDeleteNamespace(ns string) error
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error)
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error // Applications
IsRBACEnabled() (bool, error) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error)
GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error) GetApplicationsResource(namespace, node string) (models.K8sApplicationResource, error)
GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error)
DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error // ClusterRole
GetServiceAccountBearerToken(userID int) (string, error) GetClusterRoles() ([]models.K8sClusterRole, error)
CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error
// ConfigMap
GetConfigMap(namespace, configMapName string) (models.K8sConfigMap, error)
CombineConfigMapWithApplications(configMap models.K8sConfigMap) (models.K8sConfigMap, error)
// CronJob
GetCronJobs(namespace string) ([]models.K8sCronJob, error)
DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error
// Event
GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error)
// Exec
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error)
// ClusterRoleBinding
GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error)
DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error
// Dashboard
GetDashboard() (models.K8sDashboard, error)
// Deployment
HasStackName(namespace string, stackName string) (bool, error) HasStackName(namespace string, stackName string) (bool, error)
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) // Ingress
UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
GetNamespaces() (map[string]K8sNamespaceInfo, error)
GetNamespace(string) (K8sNamespaceInfo, error)
DeleteNamespace(namespace string) (*corev1.Namespace, error)
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
GetSecrets(namespace string) ([]models.K8sSecret, error)
GetIngressControllers() (models.K8sIngressControllers, error) GetIngressControllers() (models.K8sIngressControllers, error)
GetApplications(namespace, nodename string) ([]models.K8sApplication, error) GetIngress(namespace, ingressName string) (models.K8sIngressInfo, error)
GetMetrics() (models.K8sMetrics, error)
GetStorage() ([]KubernetesStorageClassConfig, error)
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
UpdateIngress(namespace string, info models.K8sIngressInfo) error
GetIngresses(namespace string) ([]models.K8sIngressInfo, error) GetIngresses(namespace string) ([]models.K8sIngressInfo, error)
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
DeleteIngresses(reqs models.K8sIngressDeleteRequests) error DeleteIngresses(reqs models.K8sIngressDeleteRequests) error
CreateService(namespace string, service models.K8sServiceInfo) error UpdateIngress(namespace string, info models.K8sIngressInfo) error
UpdateService(namespace string, service models.K8sServiceInfo) error CombineIngressWithService(ingress models.K8sIngressInfo) (models.K8sIngressInfo, error)
GetServices(namespace string) ([]models.K8sServiceInfo, error) CombineIngressesWithServices(ingresses []models.K8sIngressInfo) ([]models.K8sIngressInfo, error)
DeleteServices(reqs models.K8sServiceDeleteRequests) error
// Job
GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error)
DeleteJobs(payload models.K8sJobDeleteRequests) error
// Metrics
GetMetrics() (models.K8sMetrics, error)
// Namespace
ToggleSystemState(namespaceName string, isSystem bool) error
UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
GetNamespace(name string) (K8sNamespaceInfo, error)
CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
GetNamespaces() (map[string]K8sNamespaceInfo, error)
CombineNamespaceWithResourceQuota(namespace K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError
DeleteNamespace(namespaceName string) (*corev1.Namespace, error)
CombineNamespacesWithResourceQuotas(namespaces map[string]K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError
ConvertNamespaceMapToSlice(namespaces map[string]K8sNamespaceInfo) []K8sNamespaceInfo
// NodeLimits
GetNodesLimits() (K8sNodesLimits, error) GetNodesLimits() (K8sNodesLimits, error)
GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error) GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error)
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error // Pod
CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)
// RBAC
IsRBACEnabled() (bool, error)
// Registries
DeleteRegistrySecret(registry RegistryID, namespace string) error DeleteRegistrySecret(registry RegistryID, namespace string) error
CreateRegistrySecret(registry *Registry, namespace string) error CreateRegistrySecret(registry *Registry, namespace string) error
IsRegistrySecret(namespace, secretName string) (bool, error) IsRegistrySecret(namespace, secretName string) (bool, error)
ToggleSystemState(namespace string, isSystem bool) error
GetClusterRoles() ([]models.K8sClusterRole, error) // RoleBinding
DeleteClusterRoles(models.K8sClusterRoleDeleteRequests) error
GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error)
DeleteClusterRoleBindings(models.K8sClusterRoleBindingDeleteRequests) error
GetRoles(namespace string) ([]models.K8sRole, error)
DeleteRoles(models.K8sRoleDeleteRequests) error
GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error) GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error)
DeleteRoleBindings(models.K8sRoleBindingDeleteRequests) error DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error
// Role
DeleteRoles(reqs models.K8sRoleDeleteRequests) error
// Secret
GetSecrets(namespace string) ([]models.K8sSecret, error)
GetSecret(namespace string, secretName string) (models.K8sSecret, error)
CombineSecretWithApplications(secret models.K8sSecret) (models.K8sSecret, error)
// ServiceAccount
GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error)
DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error
SetupUserServiceAccount(int, []int, bool) error
GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error)
GetServiceAccountBearerToken(userID int) (string, error)
// Service
GetServices(namespace string) ([]models.K8sServiceInfo, error)
CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error)
CreateService(namespace string, info models.K8sServiceInfo) error
DeleteServices(reqs models.K8sServiceDeleteRequests) error
UpdateService(namespace string, info models.K8sServiceInfo) error
// ServerVersion
ServerVersion() (*version.Info, error)
// Storage
GetStorage() ([]KubernetesStorageClassConfig, error)
// Volumes
GetVolumes(namespace string) ([]models.K8sVolumeInfo, error)
GetVolume(namespace, volumeName string) (*models.K8sVolumeInfo, error)
CombineVolumesWithApplications(volumes *[]models.K8sVolumeInfo) (*[]models.K8sVolumeInfo, error)
} }
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint) // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint)

View file

@ -58,7 +58,7 @@
<resource-events-datatable <resource-events-datatable
resource-id="ctrl.configuration.Id" resource-id="ctrl.configuration.Id"
storage-key="'kubernetes.configmap.events'" storage-key="'kubernetes.configmap.events'"
namespace="ctrl.formValues.ResourcePool.Namespace.Name" namespace="ctrl.configuration.Namespace"
></resource-events-datatable> ></resource-events-datatable>
</uib-tab> </uib-tab>
<uib-tab index="2" ng-if="ctrl.configuration.Yaml" classes="btn-sm" select="ctrl.showEditor()" data-cy="k8sConfigDetail-yamlTab"> <uib-tab index="2" ng-if="ctrl.configuration.Yaml" classes="btn-sm" select="ctrl.showEditor()" data-cy="k8sConfigDetail-yamlTab">

View file

@ -65,7 +65,7 @@
<resource-events-datatable <resource-events-datatable
resource-id="ctrl.configuration.Id" resource-id="ctrl.configuration.Id"
storage-key="'kubernetes.secret.events'" storage-key="'kubernetes.secret.events'"
namespace="ctrl.formValues.ResourcePool.Namespace.Name" namespace="ctrl.configuration.Namespace"
></resource-events-datatable> ></resource-events-datatable>
</uib-tab> </uib-tab>
<uib-tab index="2" ng-if="ctrl.configuration.Yaml" classes="btn-sm" select="ctrl.showEditor()" data-cy="k8sConfigDetail-yamlTab"> <uib-tab index="2" ng-if="ctrl.configuration.Yaml" classes="btn-sm" select="ctrl.showEditor()" data-cy="k8sConfigDetail-yamlTab">

View file

@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
import { TableState } from '@@/datatables/useTableState';
import { Event } from '../../queries/types';
import { EventsDatatable } from './EventsDatatable';
// Mock the necessary hooks and dependencies
const mockTableState: TableState<TableSettings> = {
sortBy: { id: 'Date', desc: true },
pageSize: 10,
search: '',
autoRefreshRate: 0,
showSystemResources: false,
setSortBy: vi.fn(),
setPageSize: vi.fn(),
setSearch: vi.fn(),
setAutoRefreshRate: vi.fn(),
setShowSystemResources: vi.fn(),
};
vi.mock('../../datatables/default-kube-datatable-store', () => ({
useKubeStore: () => mockTableState,
}));
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
const events: Event[] = [
{
type: 'Warning',
name: 'name',
message: 'not sure if this what you want to do',
namespace: 'default',
reason: 'unknown',
count: 1,
eventTime: new Date('2025-01-02T15:04:05Z'),
uid: '4500fc9c-0cc8-4695-b4c4-989ac021d1d6',
involvedObject: {
kind: 'configMap',
uid: '35',
name: 'name',
namespace: 'default',
},
},
];
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<EventsDatatable
dataset={events}
tableState={mockTableState}
isLoading={false}
data-cy="k8sNodeDetail-eventsTable"
noWidget
/>
)),
user
)
);
return { ...render(<Wrapped />), events };
}
describe('EventsDatatable', () => {
it('should display events when data is loaded', async () => {
const { events } = renderComponent();
const event = events[0];
expect(screen.getByText(event.message || '')).toBeInTheDocument();
expect(screen.getAllByText(event.type || '')).toHaveLength(2);
expect(screen.getAllByText(event.involvedObject.kind || '')).toHaveLength(
2
);
});
});

View file

@ -1,7 +1,7 @@
import { Event } from 'kubernetes-types/core/v1';
import { History } from 'lucide-react'; import { History } from 'lucide-react';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Event } from '@/react/kubernetes/queries/types';
import { IndexOptional } from '@/react/kubernetes/configs/types'; import { IndexOptional } from '@/react/kubernetes/configs/types';
import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
@ -38,7 +38,7 @@ export function EventsDatatable({
isLoading={isLoading} isLoading={isLoading}
title={title} title={title}
titleIcon={titleIcon} titleIcon={titleIcon}
getRowId={(row) => row.metadata?.uid || ''} getRowId={(row) => row.uid || ''}
disableSelect disableSelect
renderTableSettings={() => ( renderTableSettings={() => (
<TableSettingsMenu> <TableSettingsMenu>

View file

@ -29,9 +29,7 @@ export function ResourceEventsDatatable({
params: { endpointId }, params: { endpointId },
} = useCurrentStateAndParams(); } = useCurrentStateAndParams();
const params = resourceId const params = resourceId ? { resourceId: `${resourceId}` } : {};
? { fieldSelector: `involvedObject.uid=${resourceId}` }
: {};
const resourceEventsQuery = useEvents(endpointId, { const resourceEventsQuery = useEvents(endpointId, {
namespace, namespace,
params, params,

View file

@ -1,5 +1,6 @@
import { Row } from '@tanstack/react-table'; import { Row } from '@tanstack/react-table';
import { Event } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
import { Badge, BadgeType } from '@@/Badge'; import { Badge, BadgeType } from '@@/Badge';
import { filterHOC } from '@@/datatables/Filter'; import { filterHOC } from '@@/datatables/Filter';

View file

@ -1,4 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table'; import { createColumnHelper } from '@tanstack/react-table';
import { Event } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
export const columnHelper = createColumnHelper<Event>(); export const columnHelper = createColumnHelper<Event>();

View file

@ -1,5 +1,6 @@
import { Row } from '@tanstack/react-table'; import { Row } from '@tanstack/react-table';
import { Event } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
import { filterHOC } from '@@/datatables/Filter'; import { filterHOC } from '@@/datatables/Filter';

View file

@ -184,15 +184,8 @@ describe(
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () => http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory) HttpResponse.json(helmReleaseHistory)
), ),
http.get( http.get('/api/kubernetes/3/namespaces/default/events', () =>
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events', HttpResponse.json([])
() =>
HttpResponse.json({
kind: 'EventList',
apiVersion: 'v1',
metadata: { resourceVersion: '12345' },
items: [],
})
) )
); );
@ -236,15 +229,8 @@ describe(
HttpResponse.error() HttpResponse.error()
), ),
// Add mock for events endpoint // Add mock for events endpoint
http.get( http.get('/api/kubernetes/3/namespaces/default/events', () =>
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events', HttpResponse.json([])
() =>
HttpResponse.json({
kind: 'EventList',
apiVersion: 'v1',
metadata: { resourceVersion: '12345' },
items: [],
})
) )
); );
@ -274,15 +260,8 @@ describe(
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () => http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory) HttpResponse.json(helmReleaseHistory)
), ),
http.get( http.get('/api/kubernetes/3/namespaces/default/events', () =>
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events', HttpResponse.json([])
() =>
HttpResponse.json({
kind: 'EventList',
apiVersion: 'v1',
metadata: { resourceVersion: '12345' },
items: [],
})
) )
); );

View file

@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import { HttpResponse } from 'msw'; import { HttpResponse } from 'msw';
import { Event, EventList } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
import { server, http } from '@/setup-tests/server'; import { server, http } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter'; import { withTestRouter } from '@/react/test-utils/withRouter';
@ -56,136 +56,84 @@ const testResources: GenericResource[] = [
}, },
]; ];
const mockEventsResponse: EventList = { const mockEventsResponse: Event[] = [
kind: 'EventList',
apiVersion: 'v1',
metadata: {
resourceVersion: '12345',
},
items: [
{ {
metadata: {
name: 'test-deployment-123456', name: 'test-deployment-123456',
namespace: 'default', namespace: 'default',
reason: 'CreatedLoadBalancer',
eventTime: new Date('2023-01-01T00:00:00Z'),
uid: 'event-uid-1', uid: 'event-uid-1',
resourceVersion: '1000',
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: { involvedObject: {
kind: 'Deployment', kind: 'Deployment',
namespace: 'default',
name: 'test-deployment', name: 'test-deployment',
uid: 'test-deployment-uid', uid: 'test-deployment-uid',
apiVersion: 'apps/v1', namespace: 'default',
resourceVersion: '2000',
}, },
reason: 'ScalingReplicaSet',
message: 'Scaled up replica set test-deployment-abc123 to 1', message: 'Scaled up replica set test-deployment-abc123 to 1',
source: { firstTimestamp: new Date('2023-01-01T00:00:00Z'),
component: 'deployment-controller', lastTimestamp: new Date('2023-01-01T00:00:00Z'),
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1, count: 1,
type: 'Normal', type: 'Normal',
reportingComponent: 'deployment-controller',
reportingInstance: '',
}, },
{ {
metadata: {
name: 'test-service-123456', name: 'test-service-123456',
namespace: 'default', namespace: 'default',
uid: 'event-uid-2', uid: 'event-uid-2',
resourceVersion: '1001', eventTime: new Date('2023-01-01T00:00:00Z'),
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: { involvedObject: {
kind: 'Service', kind: 'Service',
namespace: 'default', namespace: 'default',
name: 'test-service', name: 'test-service',
uid: 'test-service-uid', uid: 'test-service-uid',
apiVersion: 'v1',
resourceVersion: '2001',
}, },
reason: 'CreatedLoadBalancer', reason: 'CreatedLoadBalancer',
message: 'Created load balancer', message: 'Created load balancer',
source: { firstTimestamp: new Date('2023-01-01T00:00:00Z'),
component: 'service-controller', lastTimestamp: new Date('2023-01-01T00:00:00Z'),
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1, count: 1,
type: 'Normal', type: 'Normal',
reportingComponent: 'service-controller',
reportingInstance: '',
}, },
], ];
};
const mixedEventsResponse: EventList = { const mixedEventsResponse: Event[] = [
kind: 'EventList',
apiVersion: 'v1',
metadata: {
resourceVersion: '12345',
},
items: [
{ {
metadata: {
name: 'test-deployment-123456', name: 'test-deployment-123456',
namespace: 'default', namespace: 'default',
uid: 'event-uid-1', uid: 'event-uid-1',
resourceVersion: '1000', eventTime: new Date('2023-01-01T00:00:00Z'),
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: { involvedObject: {
kind: 'Deployment', kind: 'Deployment',
namespace: 'default', namespace: 'default',
name: 'test-deployment', name: 'test-deployment',
uid: 'test-deployment-uid', // This matches a resource UID uid: 'test-deployment-uid', // This matches a resource UID
apiVersion: 'apps/v1',
resourceVersion: '2000',
}, },
reason: 'ScalingReplicaSet', reason: 'ScalingReplicaSet',
message: 'Scaled up replica set test-deployment-abc123 to 1', message: 'Scaled up replica set test-deployment-abc123 to 1',
source: {
component: 'deployment-controller', firstTimestamp: new Date('2023-01-01T00:00:00Z'),
}, lastTimestamp: new Date('2023-01-01T00:00:00Z'),
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1, count: 1,
type: 'Normal', type: 'Normal',
reportingComponent: 'deployment-controller',
reportingInstance: '',
}, },
{ {
metadata: {
name: 'unrelated-pod-123456', name: 'unrelated-pod-123456',
namespace: 'default', namespace: 'default',
uid: 'event-uid-3', uid: 'event-uid-3',
resourceVersion: '1002', eventTime: new Date('2023-01-01T00:00:00Z'),
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: { involvedObject: {
kind: 'Pod', kind: 'Pod',
namespace: 'default', namespace: 'default',
name: 'unrelated-pod', name: 'unrelated-pod',
uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
apiVersion: 'v1',
resourceVersion: '2002',
}, },
reason: 'Scheduled', reason: 'Scheduled',
message: 'Successfully assigned unrelated-pod to node', message: 'Successfully assigned unrelated-pod to node',
source: { type: 'Normal',
component: 'default-scheduler', firstTimestamp: new Date('2023-01-01T00:00:00Z'),
}, lastTimestamp: new Date('2023-01-01T00:00:00Z'),
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1, count: 1,
reportingComponent: 'scheduler',
reportingInstance: '',
}, },
], ];
};
function renderComponent() { function renderComponent() {
const user = new UserViewModel({ Username: 'user' }); const user = new UserViewModel({ Username: 'user' });
@ -229,7 +177,7 @@ describe('HelmEventsDatatable', () => {
it('should correctly filter related events using the filterRelatedEvents function', () => { it('should correctly filter related events using the filterRelatedEvents function', () => {
const filteredEvents = filterRelatedEvents( const filteredEvents = filterRelatedEvents(
mixedEventsResponse.items as Event[], mixedEventsResponse as Event[],
testResources testResources
); );

View file

@ -1,6 +1,6 @@
import { compact } from 'lodash'; import { compact } from 'lodash';
import { Event } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable'; import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable';
import { useEvents } from '@/react/kubernetes/queries/useEvents'; import { useEvents } from '@/react/kubernetes/queries/useEvents';

View file

@ -0,0 +1,19 @@
export type Event = {
type: string;
name: string;
reason: string;
message: string;
namespace: string;
eventTime: Date;
kind?: string;
count: number;
lastTimestamp?: Date;
firstTimestamp?: Date;
uid: string;
involvedObject: {
uid: string;
kind?: string;
name: string;
namespace: string;
};
};

View file

@ -1,6 +1,6 @@
import { EventList, Event } from 'kubernetes-types/core/v1';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Event } from '@/react/kubernetes/queries/types';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import axios from '@/portainer/services/axios'; import axios from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
@ -13,10 +13,7 @@ type RequestOptions = {
/** if undefined, events are fetched at the cluster scope */ /** if undefined, events are fetched at the cluster scope */
namespace?: string; namespace?: string;
params?: { params?: {
/** https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors */ resourceId?: string;
labelSelector?: string;
/** https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors */
fieldSelector?: string;
}; };
}; };
@ -44,13 +41,13 @@ async function getEvents(
): Promise<Event[]> { ): Promise<Event[]> {
const { namespace, params } = options ?? {}; const { namespace, params } = options ?? {};
try { try {
const { data } = await axios.get<EventList>( const { data } = await axios.get<Event[]>(
buildUrl(environmentId, namespace), buildUrl(environmentId, namespace),
{ {
params, params,
} }
); );
return data.items; return data;
} catch (e) { } catch (e) {
throw parseKubernetesAxiosError(e, 'Unable to retrieve events'); throw parseKubernetesAxiosError(e, 'Unable to retrieve events');
} }
@ -96,6 +93,6 @@ export function useEventWarningsCount(
function buildUrl(environmentId: EnvironmentId, namespace?: string) { function buildUrl(environmentId: EnvironmentId, namespace?: string) {
return namespace return namespace
? `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/events` ? `/kubernetes/${environmentId}/namespaces/${namespace}/events`
: `/endpoints/${environmentId}/kubernetes/api/v1/events`; : `/kubernetes/${environmentId}/events`;
} }