From 769173b9ea3052cff7dbd35437ab39b6766f50ba Mon Sep 17 00:00:00 2001 From: Comrade Yi Date: Sun, 7 Jun 2026 10:04:23 +0800 Subject: [PATCH] Feat: Add event timeline for application/instance/service with K8s event ingestion ([#1472](https://github.com/apache/dubbo-admin/issues/1472)) - Add K8sEvent resource type (proto + Go spec) and store indexes - Add K8sEventListerWatcher to watch K8s /api/v1/events via client-go - Register K8sEventListerWatcher in Kubernetes EngineFactory - Add /application/event, /instance/event, /service/event console API endpoints - Add EventTimeline shared Vue component with normal/warning node styles - Wire up event tabs for application, instance, and service detail pages - Un-hide event tab routes in frontend router - Add mock event handlers for development - Add PlatformEvent resource type (platformevent_types.go) with store indexes - Add shared platform_event_recorder utility for event recording - Record ZK config change events (tag-route, condition-route, dynamic-config) - Record ZK metadata events (provider/consumer metadata added/updated) - Record Nacos instance registration/deregistration events - Record Nacos consumer metadata change events - Merge K8s events and Platform events into unified timeline in event query service - Downgrade "no subscriber" log level from INFO to DEBUG to reduce log noise --- api/mesh/v1alpha1/k8s_event.go | 55 +++ api/mesh/v1alpha1/k8s_event.proto | 38 ++ pkg/console/handler/event.go | 77 ++++ pkg/console/model/event.go | 43 ++ pkg/console/router/router.go | 3 + pkg/console/service/event.go | 333 +++++++++++++++ .../discovery/subscriber/nacos_service.go | 197 ++++++++- .../subscriber/platform_event_recorder.go | 107 +++++ pkg/core/discovery/subscriber/zk_config.go | 41 ++ pkg/core/discovery/subscriber/zk_metadata.go | 35 ++ pkg/core/events/component.go | 2 +- .../apis/mesh/v1alpha1/k8sevent_types.go | 166 ++++++++ .../apis/mesh/v1alpha1/platformevent_types.go | 169 ++++++++ pkg/core/store/index/k8s_event.go | 87 ++++ pkg/core/store/index/platform_event.go | 100 +++++ pkg/engine/kubernetes/factory.go | 7 + .../kubernetes/listerwatcher/k8s_event.go | 96 +++++ ui-vue3/src/api/service/app.ts | 7 +- ui-vue3/src/api/service/instance.ts | 27 +- ui-vue3/src/api/service/service.ts | 13 + ui-vue3/src/base/http/request.ts | 22 +- ui-vue3/src/base/i18n/en.ts | 1 + ui-vue3/src/base/i18n/zh.ts | 1 + ui-vue3/src/components/EventTimeline.vue | 215 ++++++++++ ui-vue3/src/mocks/handlers/app.ts | 8 +- ui-vue3/src/mocks/handlers/instance.ts | 22 +- ui-vue3/src/mocks/handlers/service.ts | 20 +- ui-vue3/src/router/defaultRoutes.ts | 3 - ui-vue3/src/types/api.ts | 7 + .../resources/applications/tabs/event.vue | 187 +++------ .../instances/slots/InstanceTabHeaderSlot.vue | 13 +- .../views/resources/instances/tabs/detail.vue | 394 +++++++++--------- .../views/resources/instances/tabs/event.vue | 65 ++- .../views/resources/services/tabs/event.vue | 114 ++--- 34 files changed, 2257 insertions(+), 418 deletions(-) create mode 100644 api/mesh/v1alpha1/k8s_event.go create mode 100644 api/mesh/v1alpha1/k8s_event.proto create mode 100644 pkg/console/handler/event.go create mode 100644 pkg/console/model/event.go create mode 100644 pkg/console/service/event.go create mode 100644 pkg/core/discovery/subscriber/platform_event_recorder.go create mode 100644 pkg/core/resource/apis/mesh/v1alpha1/k8sevent_types.go create mode 100644 pkg/core/resource/apis/mesh/v1alpha1/platformevent_types.go create mode 100644 pkg/core/store/index/k8s_event.go create mode 100644 pkg/core/store/index/platform_event.go create mode 100644 pkg/engine/kubernetes/listerwatcher/k8s_event.go create mode 100644 ui-vue3/src/components/EventTimeline.vue diff --git a/api/mesh/v1alpha1/k8s_event.go b/api/mesh/v1alpha1/k8s_event.go new file mode 100644 index 000000000..d8c7692bb --- /dev/null +++ b/api/mesh/v1alpha1/k8s_event.go @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package v1alpha1 + +// K8sEvent is the spec for a K8s event resource, capturing both K8s native events +// and registry-side lifecycle events in a unified format. +type K8sEvent struct { + Namespace string `json:"namespace,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Type string `json:"type,omitempty"` + InvolvedObjKind string `json:"involvedObjKind,omitempty"` + InvolvedObjName string `json:"involvedObjName,omitempty"` + SourceComponent string `json:"sourceComponent,omitempty"` + SourceHost string `json:"sourceHost,omitempty"` + FirstTimestamp string `json:"firstTimestamp,omitempty"` + LastTimestamp string `json:"lastTimestamp,omitempty"` + Count int32 `json:"count,omitempty"` + EventSource string `json:"eventSource,omitempty"` +} + +func (e *K8sEvent) Clone() *K8sEvent { + if e == nil { + return nil + } + return &K8sEvent{ + Namespace: e.Namespace, + Reason: e.Reason, + Message: e.Message, + Type: e.Type, + InvolvedObjKind: e.InvolvedObjKind, + InvolvedObjName: e.InvolvedObjName, + SourceComponent: e.SourceComponent, + SourceHost: e.SourceHost, + FirstTimestamp: e.FirstTimestamp, + LastTimestamp: e.LastTimestamp, + Count: e.Count, + EventSource: e.EventSource, + } +} diff --git a/api/mesh/v1alpha1/k8s_event.proto b/api/mesh/v1alpha1/k8s_event.proto new file mode 100644 index 000000000..afa063ab4 --- /dev/null +++ b/api/mesh/v1alpha1/k8s_event.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; + +package dubbo.mesh.v1alpha1; + +option go_package = "github.com/apache/dubbo-admin/api/mesh/v1alpha1"; + +import "api/mesh/options.proto"; + +message K8sEvent { + option (dubbo.mesh.resource).name = "K8sEvent"; + option (dubbo.mesh.resource).plural_name = "K8sEvents"; + option (dubbo.mesh.resource).package = "mesh"; + option (dubbo.mesh.resource).is_experimental = true; + + string namespace = 1; + + string reason = 2; + + string message = 3; + + string type = 4; + + string involvedObjKind = 5; + + string involvedObjName = 6; + + string sourceComponent = 7; + + string sourceHost = 8; + + string firstTimestamp = 9; + + string lastTimestamp = 10; + + int32 count = 11; + + string eventSource = 12; +} diff --git a/pkg/console/handler/event.go b/pkg/console/handler/event.go new file mode 100644 index 000000000..acb546f8a --- /dev/null +++ b/pkg/console/handler/event.go @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + consolectx "github.com/apache/dubbo-admin/pkg/console/context" + "github.com/apache/dubbo-admin/pkg/console/model" + "github.com/apache/dubbo-admin/pkg/console/service" + "github.com/apache/dubbo-admin/pkg/console/util" +) + +func GetApplicationEvents(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + req := &model.EventQueryReq{} + if err := c.ShouldBindQuery(req); err != nil { + util.HandleArgumentError(c, err) + return + } + resp, err := service.ListApplicationEvents(ctx, req) + if err != nil { + util.HandleServiceError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(resp)) + } +} + +func GetInstanceEvents(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + req := &model.EventQueryReq{} + if err := c.ShouldBindQuery(req); err != nil { + util.HandleArgumentError(c, err) + return + } + resp, err := service.ListInstanceEvents(ctx, req) + if err != nil { + util.HandleServiceError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(resp)) + } +} + +func GetServiceEvents(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + req := &model.EventQueryReq{} + if err := c.ShouldBindQuery(req); err != nil { + util.HandleArgumentError(c, err) + return + } + resp, err := service.ListServiceEvents(ctx, req) + if err != nil { + util.HandleServiceError(c, err) + return + } + c.JSON(http.StatusOK, model.NewSuccessResp(resp)) + } +} diff --git a/pkg/console/model/event.go b/pkg/console/model/event.go new file mode 100644 index 000000000..57b76aed8 --- /dev/null +++ b/pkg/console/model/event.go @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package model + +import ( + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type EventQueryReq struct { + AppName string `form:"appName"` + InstanceName string `form:"instanceName"` + InstanceIP string `form:"ip"` + ServiceName string `form:"serviceName"` + Mesh string `form:"mesh"` + coremodel.PageReq +} + +type EventItem struct { + Time string `json:"time"` + Type string `json:"type"` + Message string `json:"message"` + Source string `json:"source"` +} + +type EventListResp struct { + List []*EventItem `json:"list"` + Total int `json:"total"` +} diff --git a/pkg/console/router/router.go b/pkg/console/router/router.go index 24cd7d44c..5a45efe8b 100644 --- a/pkg/console/router/router.go +++ b/pkg/console/router/router.go @@ -49,6 +49,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { instanceConfig.GET("/operatorLog", handler.InstanceConfigOperatorLogGET(ctx)) instanceConfig.PUT("/operatorLog", handler.InstanceConfigOperatorLogPUT(ctx)) } + instance.GET("/event", handler.GetInstanceEvents(ctx)) instance.GET("/metric-dashboard", handler.GetGrafanaDashboard(ctx, handler.InstanceDimension, handler.MetricDashboard)) instance.GET("/trace-dashboard", handler.GetGrafanaDashboard(ctx, handler.InstanceDimension, handler.TraceDashboard)) instance.GET("/metrics-list", handler.GetMetricsList(ctx)) @@ -72,6 +73,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { applicationConfig.GET("/gray", handler.ApplicationConfigGrayGET(ctx)) applicationConfig.PUT("/gray", handler.ApplicationConfigGrayPUT(ctx)) } + application.GET("/event", handler.GetApplicationEvents(ctx)) application.GET("/metric-dashboard", handler.GetGrafanaDashboard(ctx, handler.AppDimension, handler.MetricDashboard)) application.GET("/trace-dashboard", handler.GetGrafanaDashboard(ctx, handler.AppDimension, handler.TraceDashboard)) } @@ -106,6 +108,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { service.GET("/search", handler.SearchServices(ctx)) service.GET("/graph", handler.GetServiceGraph(ctx)) service.GET("/detail", handler.GetServiceDetail(ctx)) + service.GET("/event", handler.GetServiceEvents(ctx)) service.GET("/interfaces", handler.GetServiceInterfaces(ctx)) } diff --git a/pkg/console/service/event.go b/pkg/console/service/event.go new file mode 100644 index 000000000..c08b7515d --- /dev/null +++ b/pkg/console/service/event.go @@ -0,0 +1,333 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package service + +import ( + "sort" + "strings" + "time" + + consolectx "github.com/apache/dubbo-admin/pkg/console/context" + "github.com/apache/dubbo-admin/pkg/console/model" + "github.com/apache/dubbo-admin/pkg/core/manager" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" + "github.com/apache/dubbo-admin/pkg/core/store/index" +) + +func ListApplicationEvents(ctx consolectx.Context, req *model.EventQueryReq) (*model.EventListResp, error) { + k8sEvents, err := manager.ListByIndexes[*meshresource.K8sEventResource]( + ctx.ResourceManager(), + meshresource.K8sEventKind, + buildApplicationK8sConditions(req), + ) + if err != nil { + return nil, err + } + + platformEvents, err := manager.ListByIndexes[*meshresource.PlatformEventResource]( + ctx.ResourceManager(), + meshresource.PlatformEventKind, + buildApplicationPlatformConditions(req), + ) + if err != nil { + return nil, err + } + + return buildEventListResp(req.PageReq, toApplicationEventEntries(k8sEvents, platformEvents)), nil +} + +func ListInstanceEvents(ctx consolectx.Context, req *model.EventQueryReq) (*model.EventListResp, error) { + k8sEvents, err := manager.ListByIndexes[*meshresource.K8sEventResource]( + ctx.ResourceManager(), + meshresource.K8sEventKind, + []index.IndexCondition{ + {IndexName: index.ByMeshIndex, Value: req.Mesh, Operator: index.Equals}, + }, + ) + if err != nil { + return nil, err + } + + platformEvents, err := manager.ListByIndexes[*meshresource.PlatformEventResource]( + ctx.ResourceManager(), + meshresource.PlatformEventKind, + []index.IndexCondition{ + {IndexName: index.ByMeshIndex, Value: req.Mesh, Operator: index.Equals}, + }, + ) + if err != nil { + return nil, err + } + + return buildEventListResp(req.PageReq, toInstanceEventEntries(req, k8sEvents, platformEvents)), nil +} + +func ListServiceEvents(ctx consolectx.Context, req *model.EventQueryReq) (*model.EventListResp, error) { + k8sEvents, err := manager.ListByIndexes[*meshresource.K8sEventResource]( + ctx.ResourceManager(), + meshresource.K8sEventKind, + buildServiceK8sConditions(req), + ) + if err != nil { + return nil, err + } + + platformEvents, err := manager.ListByIndexes[*meshresource.PlatformEventResource]( + ctx.ResourceManager(), + meshresource.PlatformEventKind, + buildServicePlatformConditions(req), + ) + if err != nil { + return nil, err + } + + return buildEventListResp(req.PageReq, toServiceEventEntries(k8sEvents, platformEvents)), nil +} + +type eventEntry struct { + timeValue time.Time + event *model.EventItem +} + +func buildApplicationK8sConditions(req *model.EventQueryReq) []index.IndexCondition { + conditions := []index.IndexCondition{ + {IndexName: index.ByMeshIndex, Value: req.Mesh, Operator: index.Equals}, + } + if req.AppName != "" { + conditions = append(conditions, index.IndexCondition{ + IndexName: index.ByK8sEventInvolvedObjName, + Value: req.AppName, + Operator: index.HasPrefix, + }) + } + return conditions +} + +func buildApplicationPlatformConditions(req *model.EventQueryReq) []index.IndexCondition { + conditions := []index.IndexCondition{ + {IndexName: index.ByMeshIndex, Value: req.Mesh, Operator: index.Equals}, + } + if req.AppName != "" { + conditions = append(conditions, index.IndexCondition{ + IndexName: index.ByPlatformEventAppName, + Value: req.AppName, + Operator: index.Equals, + }) + } + return conditions +} + +func buildServiceK8sConditions(req *model.EventQueryReq) []index.IndexCondition { + conditions := []index.IndexCondition{ + {IndexName: index.ByMeshIndex, Value: req.Mesh, Operator: index.Equals}, + } + if req.ServiceName != "" { + conditions = append(conditions, index.IndexCondition{ + IndexName: index.ByK8sEventInvolvedObjName, + Value: req.ServiceName, + Operator: index.Equals, + }) + } + return conditions +} + +func buildServicePlatformConditions(req *model.EventQueryReq) []index.IndexCondition { + conditions := []index.IndexCondition{ + {IndexName: index.ByMeshIndex, Value: req.Mesh, Operator: index.Equals}, + } + if req.ServiceName != "" { + conditions = append(conditions, index.IndexCondition{ + IndexName: index.ByPlatformEventServiceName, + Value: req.ServiceName, + Operator: index.Equals, + }) + } + return conditions +} + +func toApplicationEventEntries( + k8sEvents []*meshresource.K8sEventResource, + platformEvents []*meshresource.PlatformEventResource, +) []eventEntry { + result := make([]eventEntry, 0, len(k8sEvents)+len(platformEvents)) + result = append(result, toK8sEventEntries(k8sEvents)...) + result = append(result, toPlatformEventEntries(platformEvents)...) + return result +} + +func toInstanceEventEntries( + req *model.EventQueryReq, + k8sEvents []*meshresource.K8sEventResource, + platformEvents []*meshresource.PlatformEventResource, +) []eventEntry { + result := make([]eventEntry, 0, len(k8sEvents)+len(platformEvents)) + + for _, eventRes := range k8sEvents { + if eventRes == nil || eventRes.Spec == nil { + continue + } + involvedName := eventRes.Spec.InvolvedObjName + if req.InstanceName != "" || req.InstanceIP != "" { + if involvedName != req.InstanceName && involvedName != req.InstanceIP { + continue + } + } + result = append(result, eventEntry{ + timeValue: parseEventTime(eventRes.Spec.LastTimestamp), + event: &model.EventItem{ + Time: eventRes.Spec.LastTimestamp, + Type: toEventType(eventRes.Spec.Type), + Message: eventRes.Spec.Message, + Source: defaultK8sSource(eventRes), + }, + }) + } + + for _, eventRes := range platformEvents { + if eventRes == nil || eventRes.Spec == nil { + continue + } + if req.InstanceName != "" || req.InstanceIP != "" { + if eventRes.Spec.InstanceName != req.InstanceName && eventRes.Spec.InstanceIP != req.InstanceIP { + continue + } + } + result = append(result, eventEntry{ + timeValue: parseEventTime(eventRes.Spec.EventTime), + event: &model.EventItem{ + Time: eventRes.Spec.EventTime, + Type: toEventType(eventRes.Spec.Type), + Message: eventRes.Spec.Message, + Source: eventRes.Spec.Source, + }, + }) + } + + return result +} + +func toServiceEventEntries( + k8sEvents []*meshresource.K8sEventResource, + platformEvents []*meshresource.PlatformEventResource, +) []eventEntry { + result := make([]eventEntry, 0, len(k8sEvents)+len(platformEvents)) + result = append(result, toK8sEventEntries(k8sEvents)...) + result = append(result, toPlatformEventEntries(platformEvents)...) + return result +} + +func toK8sEventEntries(items []*meshresource.K8sEventResource) []eventEntry { + result := make([]eventEntry, 0, len(items)) + for _, eventRes := range items { + if eventRes == nil || eventRes.Spec == nil { + continue + } + result = append(result, eventEntry{ + timeValue: parseEventTime(eventRes.Spec.LastTimestamp), + event: &model.EventItem{ + Time: eventRes.Spec.LastTimestamp, + Type: toEventType(eventRes.Spec.Type), + Message: eventRes.Spec.Message, + Source: defaultK8sSource(eventRes), + }, + }) + } + return result +} + +func toPlatformEventEntries(items []*meshresource.PlatformEventResource) []eventEntry { + result := make([]eventEntry, 0, len(items)) + for _, eventRes := range items { + if eventRes == nil || eventRes.Spec == nil { + continue + } + result = append(result, eventEntry{ + timeValue: parseEventTime(eventRes.Spec.EventTime), + event: &model.EventItem{ + Time: eventRes.Spec.EventTime, + Type: toEventType(eventRes.Spec.Type), + Message: eventRes.Spec.Message, + Source: eventRes.Spec.Source, + }, + }) + } + return result +} + +func buildEventListResp(pageReq coremodel.PageReq, entries []eventEntry) *model.EventListResp { + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].timeValue.Equal(entries[j].timeValue) { + return entries[i].event.Message > entries[j].event.Message + } + return entries[i].timeValue.After(entries[j].timeValue) + }) + + total := len(entries) + start := pageReq.PageOffset + if start > total { + start = total + } + end := start + pageReq.PageSize + if pageReq.PageSize <= 0 || end > total { + end = total + } + + list := make([]*model.EventItem, 0, end-start) + for _, entry := range entries[start:end] { + list = append(list, entry.event) + } + + return &model.EventListResp{ + List: list, + Total: total, + } +} + +func parseEventTime(raw string) time.Time { + if raw == "" { + return time.Time{} + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z0700", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, raw); err == nil { + return t + } + } + return time.Time{} +} + +func toEventType(raw string) string { + if strings.EqualFold(raw, "warning") { + return "warning" + } + return "normal" +} + +func defaultK8sSource(eventRes *meshresource.K8sEventResource) string { + source := eventRes.Spec.SourceComponent + if source == "" { + source = eventRes.Spec.EventSource + } + return source +} diff --git a/pkg/core/discovery/subscriber/nacos_service.go b/pkg/core/discovery/subscriber/nacos_service.go index 4d8495cec..3a6e17bd3 100644 --- a/pkg/core/discovery/subscriber/nacos_service.go +++ b/pkg/core/discovery/subscriber/nacos_service.go @@ -78,14 +78,14 @@ func (n *NacosServiceEventSubscriber) ProcessEvent(event events.Event) error { logger.Errorf(errStr) return bizerror.New(bizerror.EventError, errStr) } - processErr = n.processUpsert(newObj) + processErr = n.processUpsert(oldObj, newObj, event.Type()) case cache.Deleted: if oldObj == nil { errStr := "process nacos service delete event, but old obj is nil, skipped processing" logger.Errorf(errStr) return bizerror.New(bizerror.EventError, errStr) } - processErr = n.processDelete(oldObj) + processErr = n.processDelete(oldObj, event.Type()) } if processErr != nil { logger.Errorf("process nacos service event failed, cause: %s, event: %s", processErr.Error(), event.String()) @@ -95,7 +95,11 @@ func (n *NacosServiceEventSubscriber) ProcessEvent(event events.Event) error { return nil } -func (n *NacosServiceEventSubscriber) processUpsert(serviceRes *meshresource.NacosServiceResource) error { +func (n *NacosServiceEventSubscriber) processUpsert( + oldServiceRes *meshresource.NacosServiceResource, + serviceRes *meshresource.NacosServiceResource, + eventType cache.DeltaType, +) error { providerRe := regexp.MustCompile(`^providers:[\w.]+(?::[\w.]*:|::[\w.]*)?$`) consumerRe := regexp.MustCompile(`^consumers:[\w.]+(?::[\w.]*:|::[\w.]*)?$`) if providerRe.MatchString(serviceRes.Name) { @@ -103,13 +107,17 @@ func (n *NacosServiceEventSubscriber) processUpsert(serviceRes *meshresource.Nac return nil } if consumerRe.MatchString(serviceRes.Name) { - return n.processConsumerMetadataUpsert(serviceRes) + return n.processConsumerMetadataUpsert(oldServiceRes, serviceRes, eventType) } - return n.processRPCInstanceUpsert(serviceRes) + return n.processRPCInstanceUpsert(oldServiceRes, serviceRes, eventType) } -func (n *NacosServiceEventSubscriber) processConsumerMetadataUpsert(serviceRes *meshresource.NacosServiceResource) error { +func (n *NacosServiceEventSubscriber) processConsumerMetadataUpsert( + oldServiceRes *meshresource.NacosServiceResource, + serviceRes *meshresource.NacosServiceResource, + eventType cache.DeltaType, +) error { serviceName, err := parseServiceName(serviceRes.Name) if err != nil { return bizerror.Wrap(err, bizerror.UnknownError, "parse service name error, raw nacos service is"+serviceRes.String()) @@ -183,6 +191,7 @@ func (n *NacosServiceEventSubscriber) processConsumerMetadataUpsert(serviceRes * n.emitter.Send(events.NewResourceChangedEvent(cache.Updated, item, item)) }) + n.recordNacosConsumerEvents(oldServiceRes, serviceRes, eventType, serviceName) return nil } @@ -201,7 +210,11 @@ func parseServiceName(s string) (string, error) { return parts[0], nil } -func (n *NacosServiceEventSubscriber) processRPCInstanceUpsert(serviceRes *meshresource.NacosServiceResource) error { +func (n *NacosServiceEventSubscriber) processRPCInstanceUpsert( + oldServiceRes *meshresource.NacosServiceResource, + serviceRes *meshresource.NacosServiceResource, + eventType cache.DeltaType, +) error { convertFunc := func(i int, instance *meshproto.NacosInstance) maputil.Entry[string, *meshresource.RPCInstanceResource] { res := meshresource.ToRPCInstance(serviceRes.Mesh, serviceRes.Name, instance.Ip, instance.Port, instance.Metadata) @@ -269,10 +282,11 @@ func (n *NacosServiceEventSubscriber) processRPCInstanceUpsert(serviceRes *meshr }) logger.Debugf("process rpc instance upsert event, oldInstances: %s, newInstances: %s, offlineInstances: %s, addInstances: %s, updateInstances: %s", maputil.Keys(oldInstances), maputil.Keys(newInstances), maputil.Keys(offlineInstances), maputil.Keys(addInstances), updateInstances) + n.recordNacosInstanceEvents(oldServiceRes, serviceRes, eventType) return nil } -func (n *NacosServiceEventSubscriber) processDelete(serviceRes *meshresource.NacosServiceResource) error { +func (n *NacosServiceEventSubscriber) processDelete(serviceRes *meshresource.NacosServiceResource, eventType cache.DeltaType) error { providerRe := regexp.MustCompile(`^providers:[\w.]+(?::[\w.]*:|::[\w.]*)?$`) consumerRe := regexp.MustCompile(`^consumers:[\w.]+(?::[\w.]*:|::[\w.]*)?$`) if providerRe.MatchString(serviceRes.Name) { @@ -280,20 +294,24 @@ func (n *NacosServiceEventSubscriber) processDelete(serviceRes *meshresource.Nac return nil } if consumerRe.MatchString(serviceRes.Name) { - return n.processServiceConsumerDelete(serviceRes) + return n.processServiceConsumerDelete(serviceRes, eventType) } - return n.processRPCInstanceDelete(serviceRes) + return n.processRPCInstanceDelete(serviceRes, eventType) } -func (n *NacosServiceEventSubscriber) processServiceConsumerDelete(serviceRes *meshresource.NacosServiceResource) error { +func (n *NacosServiceEventSubscriber) processServiceConsumerDelete(serviceRes *meshresource.NacosServiceResource, eventType cache.DeltaType) error { st, err := n.storeRouter.ResourceKindRoute(meshresource.ServiceConsumerMetadataKind) if err != nil { logger.Errorf("process service consumer delete event, but cannot route to service consumer metadata resource, cause: %v", err) return err } + serviceName, parseErr := parseServiceName(serviceRes.Name) + if parseErr != nil { + return bizerror.Wrap(parseErr, bizerror.UnknownError, "parse service name error, raw nacos service is"+serviceRes.String()) + } resources, err := st.ListByIndexes([]index.IndexCondition{ {IndexName: index.ByMeshIndex, Value: serviceRes.Mesh, Operator: index.Equals}, - {IndexName: index.ByServiceConsumerServiceName, Value: serviceRes.Name, Operator: index.Equals}, + {IndexName: index.ByServiceConsumerServiceName, Value: serviceName, Operator: index.Equals}, }) if err != nil { logger.Errorf("process service consumer delete event, but cannot list service consumer metadata resource of %s, cause: %v", serviceRes.Name, err) @@ -306,10 +324,11 @@ func (n *NacosServiceEventSubscriber) processServiceConsumerDelete(serviceRes *m } n.emitter.Send(events.NewResourceChangedEvent(cache.Deleted, item, nil)) }) + n.recordNacosConsumerEvents(serviceRes, nil, eventType, serviceName) return nil } -func (n *NacosServiceEventSubscriber) processRPCInstanceDelete(serviceRes *meshresource.NacosServiceResource) error { +func (n *NacosServiceEventSubscriber) processRPCInstanceDelete(serviceRes *meshresource.NacosServiceResource, eventType cache.DeltaType) error { st, err := n.storeRouter.ResourceKindRoute(meshresource.RPCInstanceKind) if err != nil { logger.Errorf("process rpc instance delete event, but cannot route to rpc instance resource, cause: %v", err) @@ -330,5 +349,157 @@ func (n *NacosServiceEventSubscriber) processRPCInstanceDelete(serviceRes *meshr } n.emitter.Send(events.NewResourceChangedEvent(cache.Deleted, item, nil)) }) + n.recordNacosInstanceEvents(serviceRes, nil, eventType) return nil } + +func (n *NacosServiceEventSubscriber) recordNacosInstanceEvents( + oldServiceRes *meshresource.NacosServiceResource, + newServiceRes *meshresource.NacosServiceResource, + eventType cache.DeltaType, +) { + if eventType != cache.Added && eventType != cache.Updated && eventType != cache.Deleted { + return + } + + oldInstances := buildNacosRPCInstanceMap(oldServiceRes) + newInstances := buildNacosRPCInstanceMap(newServiceRes) + + for key, item := range maputil.Minus(oldInstances, newInstances) { + recordPlatformEvent(n.storeRouter, platformEventInput{ + Mesh: item.Mesh, + Source: "Nacos", + SourceType: "nacos", + Category: "registry", + Action: "deregistered", + Message: fmt.Sprintf("Nacos instance deregistered: %s (%s:%d)", item.Spec.Name, item.Spec.Ip, item.Spec.Port), + AppName: item.Spec.AppName, + InstanceName: item.Spec.Name, + InstanceIP: item.Spec.Ip, + }) + delete(oldInstances, key) + } + + for _, item := range maputil.Values(maputil.Minus(newInstances, oldInstances)) { + recordPlatformEvent(n.storeRouter, platformEventInput{ + Mesh: item.Mesh, + Source: "Nacos", + SourceType: "nacos", + Category: "registry", + Action: "registered", + Message: fmt.Sprintf("Nacos instance registered: %s (%s:%d)", item.Spec.Name, item.Spec.Ip, item.Spec.Port), + AppName: item.Spec.AppName, + InstanceName: item.Spec.Name, + InstanceIP: item.Spec.Ip, + }) + } + + for key, newItem := range newInstances { + oldItem, exists := oldInstances[key] + if !exists || oldItem == nil || oldItem.Spec == nil || newItem.Spec == nil { + continue + } + if reflect.DeepEqual(oldItem.Spec, newItem.Spec) { + continue + } + recordPlatformEvent(n.storeRouter, platformEventInput{ + Mesh: newItem.Mesh, + Source: "Nacos", + SourceType: "nacos", + Category: "registry", + Action: "updated", + Message: fmt.Sprintf("Nacos instance metadata updated: %s (%s:%d)", newItem.Spec.Name, newItem.Spec.Ip, newItem.Spec.Port), + AppName: newItem.Spec.AppName, + InstanceName: newItem.Spec.Name, + InstanceIP: newItem.Spec.Ip, + }) + } +} + +func (n *NacosServiceEventSubscriber) recordNacosConsumerEvents( + oldServiceRes *meshresource.NacosServiceResource, + newServiceRes *meshresource.NacosServiceResource, + eventType cache.DeltaType, + serviceName string, +) { + if eventType != cache.Added && eventType != cache.Updated && eventType != cache.Deleted { + return + } + + oldConsumers := buildNacosConsumerMap(oldServiceRes) + newConsumers := buildNacosConsumerMap(newServiceRes) + + for key, item := range maputil.Minus(oldConsumers, newConsumers) { + recordPlatformEvent(n.storeRouter, platformEventInput{ + Mesh: item.Mesh, + Source: "Nacos", + SourceType: "nacos", + Category: "metadata", + Action: "consumer-removed", + Message: fmt.Sprintf("Nacos consumer metadata removed: %s -> %s", item.Spec.ConsumerAppName, serviceName), + AppName: item.Spec.ConsumerAppName, + ServiceName: serviceName, + }) + delete(oldConsumers, key) + } + + for _, item := range maputil.Values(maputil.Minus(newConsumers, oldConsumers)) { + recordPlatformEvent(n.storeRouter, platformEventInput{ + Mesh: item.Mesh, + Source: "Nacos", + SourceType: "nacos", + Category: "metadata", + Action: "consumer-added", + Message: fmt.Sprintf("Nacos consumer metadata added: %s -> %s", item.Spec.ConsumerAppName, serviceName), + AppName: item.Spec.ConsumerAppName, + ServiceName: serviceName, + }) + } + + for key, newItem := range newConsumers { + oldItem, exists := oldConsumers[key] + if !exists || oldItem == nil || oldItem.Spec == nil || newItem.Spec == nil { + continue + } + if reflect.DeepEqual(oldItem.Spec, newItem.Spec) { + continue + } + recordPlatformEvent(n.storeRouter, platformEventInput{ + Mesh: newItem.Mesh, + Source: "Nacos", + SourceType: "nacos", + Category: "metadata", + Action: "consumer-updated", + Message: fmt.Sprintf("Nacos consumer metadata updated: %s -> %s", newItem.Spec.ConsumerAppName, serviceName), + AppName: newItem.Spec.ConsumerAppName, + ServiceName: serviceName, + }) + } +} + +func buildNacosRPCInstanceMap(serviceRes *meshresource.NacosServiceResource) map[string]*meshresource.RPCInstanceResource { + result := make(map[string]*meshresource.RPCInstanceResource) + if serviceRes == nil || serviceRes.Spec == nil { + return result + } + for _, item := range serviceRes.Spec.Instances { + res := meshresource.ToRPCInstance(serviceRes.Mesh, serviceRes.Name, item.Ip, item.Port, item.Metadata) + result[res.ResourceKey()] = res + } + return result +} + +func buildNacosConsumerMap(serviceRes *meshresource.NacosServiceResource) map[string]*meshresource.ServiceConsumerMetadataResource { + result := make(map[string]*meshresource.ServiceConsumerMetadataResource) + if serviceRes == nil || serviceRes.Spec == nil { + return result + } + for _, item := range serviceRes.Spec.Instances { + res := meshresource.ToServiceConsumerMetadataByMap(item.Metadata, serviceRes.Mesh) + if res == nil { + continue + } + result[res.ResourceKey()] = res + } + return result +} diff --git a/pkg/core/discovery/subscriber/platform_event_recorder.go b/pkg/core/discovery/subscriber/platform_event_recorder.go new file mode 100644 index 000000000..e24e3e18c --- /dev/null +++ b/pkg/core/discovery/subscriber/platform_event_recorder.go @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package subscriber + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/apache/dubbo-admin/pkg/common/constants" + "github.com/apache/dubbo-admin/pkg/core/logger" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/store" +) + +var platformEventNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +type platformEventInput struct { + Mesh string + Source string + SourceType string + Category string + Action string + Message string + Type string + AppName string + InstanceName string + InstanceIP string + ServiceName string +} + +func recordPlatformEvent(storeRouter store.Router, input platformEventInput) { + if input.Mesh == "" || input.Message == "" { + return + } + eventStore, err := storeRouter.ResourceKindRoute(meshresource.PlatformEventKind) + if err != nil { + logger.Errorf("route platform event store failed, cause: %v", err) + return + } + now := time.Now() + nameSeed := strings.Join([]string{ + input.SourceType, + input.Category, + input.Action, + input.AppName, + input.ServiceName, + input.InstanceName, + input.InstanceIP, + }, "-") + res := meshresource.NewPlatformEventResourceWithAttributes(buildPlatformEventName(now, nameSeed), input.Mesh) + res.Spec = &meshresource.PlatformEvent{ + EventTime: now.Format(constants.TimeFormatStr), + Type: normalizePlatformEventType(input.Type), + Source: defaultString(input.Source, input.SourceType), + SourceType: input.SourceType, + Category: input.Category, + Action: input.Action, + Message: input.Message, + AppName: input.AppName, + InstanceName: input.InstanceName, + InstanceIP: input.InstanceIP, + ServiceName: input.ServiceName, + } + if err := eventStore.Add(res); err != nil { + logger.Errorf("record platform event failed, key: %s, cause: %v", res.ResourceKey(), err) + } +} + +func buildPlatformEventName(now time.Time, seed string) string { + sanitized := platformEventNameSanitizer.ReplaceAllString(seed, "-") + sanitized = strings.Trim(sanitized, "-") + if sanitized == "" { + sanitized = "event" + } + return fmt.Sprintf("%d-%s", now.UnixNano(), sanitized) +} + +func normalizePlatformEventType(eventType string) string { + if strings.EqualFold(eventType, "warning") { + return "warning" + } + return "normal" +} + +func defaultString(v, fallback string) string { + if v != "" { + return v + } + return fallback +} diff --git a/pkg/core/discovery/subscriber/zk_config.go b/pkg/core/discovery/subscriber/zk_config.go index 07f9a2620..11db68335 100644 --- a/pkg/core/discovery/subscriber/zk_config.go +++ b/pkg/core/discovery/subscriber/zk_config.go @@ -167,6 +167,7 @@ func processConfigUpsert[T coremodel.Resource]( logger.Errorf("add rule %s to store failed, cause: %s", newRuleRes.ResourceKey(), err.Error()) return err } + recordConfigPlatformEvent(router, newRuleRes, "added") emitter.Send(events.NewResourceChangedEvent(cache.Added, nil, newRuleRes)) return nil } @@ -184,6 +185,7 @@ func processConfigUpsert[T coremodel.Resource]( return bizerror.NewAssertionError(reflect.TypeOf(oldMetadataRes), oldRes) } + recordConfigPlatformEvent(router, newRuleRes, "updated") emitter.Send(events.NewResourceChangedEvent(cache.Updated, oldMetadataRes, newRuleRes)) return nil } @@ -217,6 +219,45 @@ func processConfigDelete[T coremodel.Resource]( logger.Errorf("delete rule %s from store failed, cause: %s", ruleRes.ResourceKey(), err.Error()) return err } + recordConfigPlatformEvent(router, oldRuleRes, "deleted") emitter.Send(events.NewResourceChangedEvent(cache.Deleted, oldRuleRes, nil)) return nil } + +func recordConfigPlatformEvent(router store.Router, res coremodel.Resource, action string) { + serviceName, category := extractRuleEventContext(res) + if serviceName == "" { + return + } + recordPlatformEvent(router, platformEventInput{ + Mesh: res.ResourceMesh(), + Source: "Zookeeper", + SourceType: "zookeeper", + Category: category, + Action: action, + Message: fmt.Sprintf("Zookeeper %s %s: %s", category, action, serviceName), + ServiceName: serviceName, + }) +} + +func extractRuleEventContext(res coremodel.Resource) (string, string) { + switch item := res.(type) { + case *meshresource.TagRouteResource: + if item.Spec == nil { + return "", "" + } + return item.Spec.Key, "tag-route" + case *meshresource.ConditionRouteResource: + if item.Spec == nil { + return "", "" + } + return item.Spec.Key, "condition-route" + case *meshresource.DynamicConfigResource: + if item.Spec == nil { + return "", "" + } + return item.Spec.Key, "dynamic-config" + default: + return "", "" + } +} diff --git a/pkg/core/discovery/subscriber/zk_metadata.go b/pkg/core/discovery/subscriber/zk_metadata.go index 4a744119e..134a4fc36 100644 --- a/pkg/core/discovery/subscriber/zk_metadata.go +++ b/pkg/core/discovery/subscriber/zk_metadata.go @@ -136,6 +136,7 @@ func processMetadataUpsert[T coremodel.Resource]( logger.Errorf("add metadata %s to store failed, cause: %s", newMetadataRes.ResourceKey(), err.Error()) return err } + recordMetadataPlatformEvent(router, newMetadataRes, "added") emitter.Send(events.NewResourceChangedEvent(cache.Added, nil, newMetadataRes)) return nil } @@ -153,6 +154,40 @@ func processMetadataUpsert[T coremodel.Resource]( return bizerror.NewAssertionError(reflect.TypeOf(oldMetadataRes), oldRes) } + recordMetadataPlatformEvent(router, newMetadataRes, "updated") emitter.Send(events.NewResourceChangedEvent(cache.Updated, oldMetadataRes, newMetadataRes)) return nil } + +func recordMetadataPlatformEvent(router store.Router, res coremodel.Resource, action string) { + switch item := res.(type) { + case *meshresource.ServiceProviderMetadataResource: + if item.Spec == nil { + return + } + recordPlatformEvent(router, platformEventInput{ + Mesh: item.Mesh, + Source: "Zookeeper", + SourceType: "zookeeper", + Category: "metadata", + Action: action, + Message: fmt.Sprintf("Zookeeper provider metadata %s: %s -> %s", action, item.Spec.ProviderAppName, item.Spec.ServiceName), + AppName: item.Spec.ProviderAppName, + ServiceName: item.Spec.ServiceName, + }) + case *meshresource.ServiceConsumerMetadataResource: + if item.Spec == nil { + return + } + recordPlatformEvent(router, platformEventInput{ + Mesh: item.Mesh, + Source: "Zookeeper", + SourceType: "zookeeper", + Category: "metadata", + Action: action, + Message: fmt.Sprintf("Zookeeper consumer metadata %s: %s -> %s", action, item.Spec.ConsumerAppName, item.Spec.ServiceName), + AppName: item.Spec.ConsumerAppName, + ServiceName: item.Spec.ServiceName, + }) + } +} diff --git a/pkg/core/events/component.go b/pkg/core/events/component.go index 7592dbaa6..f5fbf6fe8 100644 --- a/pkg/core/events/component.go +++ b/pkg/core/events/component.go @@ -178,7 +178,7 @@ func (b *eventBus) Send(event Event) { } states, exists := b.subscriberDir[rk] if !exists { - logger.Infof("no subscriber for resource %s, skipped sending event%v", rk, event) + logger.Debugf("no subscriber for resource %s, skipped sending event%v", rk, event) return } for _, st := range states { diff --git a/pkg/core/resource/apis/mesh/v1alpha1/k8sevent_types.go b/pkg/core/resource/apis/mesh/v1alpha1/k8sevent_types.go new file mode 100644 index 000000000..096c27853 --- /dev/null +++ b/pkg/core/resource/apis/mesh/v1alpha1/k8sevent_types.go @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package v1alpha1 + +import ( + "encoding/json" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/core/logger" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +const K8sEventKind coremodel.ResourceKind = "K8sEvent" + +func init() { + coremodel.RegisterResourceSchema(K8sEventKind, NewK8sEventResource, NewK8sEventResourceList) +} + +type K8sEventResource struct { + metav1.TypeMeta `json:",inline"` + + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Mesh is the name of the dubbo mesh this resource belongs to. + Mesh string `json:"mesh,omitempty"` + + // Spec is the specification of the K8sEvent resource. + Spec *meshproto.K8sEvent `json:"spec,omitempty"` + + // Status is the status of the K8sEvent resource. + Status K8sEventResourceStatus `json:"status,omitempty"` +} + +type K8sEventResourceStatus struct{} + +func (r *K8sEventResource) ResourceKind() coremodel.ResourceKind { + return K8sEventKind +} + +func (r *K8sEventResource) ResourceMesh() string { + return r.Mesh +} + +func (r *K8sEventResource) ResourceKey() string { + return coremodel.BuildResourceKey(r.Mesh, r.Name) +} + +func (r *K8sEventResource) ResourceMeta() metav1.ObjectMeta { + return r.ObjectMeta +} + +func (r *K8sEventResource) ResourceSpec() coremodel.ResourceSpec { + return r.Spec +} + +func (r *K8sEventResource) DeepCopyObject() k8sruntime.Object { + out := &K8sEventResource{ + TypeMeta: r.TypeMeta, + Mesh: r.Mesh, + Status: r.Status, + } + + r.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + + if r.Spec != nil { + out.Spec = r.Spec.Clone() + } + + return out +} + +func (r *K8sEventResource) String() string { + jsonStr, err := json.Marshal(r) + if err != nil { + logger.Errorf("failed to encode K8sEventResource: %s to json, err: %v", r.ResourceKey(), err) + return "" + } + return string(jsonStr) +} + +func NewK8sEventResourceWithAttributes(name string, mesh string) *K8sEventResource { + return &K8sEventResource{ + TypeMeta: metav1.TypeMeta{ + Kind: string(K8sEventKind), + APIVersion: "v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{}, + }, + Mesh: mesh, + Spec: &meshproto.K8sEvent{}, + } +} + +func NewK8sEventResource() coremodel.Resource { + return &K8sEventResource{ + TypeMeta: metav1.TypeMeta{ + Kind: string(K8sEventKind), + APIVersion: "v1alpha1", + }, + Spec: &meshproto.K8sEvent{}, + } +} + +type K8sEventResourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []*K8sEventResource `json:"items"` +} + +func (r *K8sEventResourceList) DeepCopyObject() k8sruntime.Object { + out := &K8sEventResourceList{ + TypeMeta: r.TypeMeta, + } + r.ListMeta.DeepCopyInto(&out.ListMeta) + + if len(r.Items) == 0 { + return out + } + out.Items = make([]*K8sEventResource, len(r.Items)) + for i := range r.Items { + out.Items[i] = r.Items[i].DeepCopyObject().(*K8sEventResource) + } + return out +} + +func NewK8sEventResourceList() coremodel.ResourceList { + return &K8sEventResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: string(K8sEventKind), + APIVersion: "v1alpha1", + }, + Items: make([]*K8sEventResource, 0), + } +} + +func (r *K8sEventResourceList) SetItems(items []coremodel.Resource) { + r.Items = make([]*K8sEventResource, len(items)) + for i := range items { + res, ok := items[i].(*K8sEventResource) + if !ok { + logger.Errorf("unexpected resource type, expected: %s, get %s", K8sEventKind, res.ResourceKind()) + continue + } + r.Items[i] = res + } +} diff --git a/pkg/core/resource/apis/mesh/v1alpha1/platformevent_types.go b/pkg/core/resource/apis/mesh/v1alpha1/platformevent_types.go new file mode 100644 index 000000000..27c03ebdc --- /dev/null +++ b/pkg/core/resource/apis/mesh/v1alpha1/platformevent_types.go @@ -0,0 +1,169 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package v1alpha1 + +import ( + "encoding/json" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + + "github.com/apache/dubbo-admin/pkg/core/logger" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +const PlatformEventKind coremodel.ResourceKind = "PlatformEvent" + +func init() { + coremodel.RegisterResourceSchema(PlatformEventKind, NewPlatformEventResource, NewPlatformEventResourceList) +} + +type PlatformEvent struct { + EventTime string `json:"eventTime,omitempty"` + Type string `json:"type,omitempty"` + Source string `json:"source,omitempty"` + SourceType string `json:"sourceType,omitempty"` + Category string `json:"category,omitempty"` + Action string `json:"action,omitempty"` + Message string `json:"message,omitempty"` + AppName string `json:"appName,omitempty"` + InstanceName string `json:"instanceName,omitempty"` + InstanceIP string `json:"instanceIp,omitempty"` + ServiceName string `json:"serviceName,omitempty"` +} + +type PlatformEventResource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Mesh string `json:"mesh,omitempty"` + Spec *PlatformEvent `json:"spec,omitempty"` + Status PlatformEventResourceStatus `json:"status,omitempty"` +} + +type PlatformEventResourceStatus struct{} + +func (r *PlatformEventResource) ResourceKind() coremodel.ResourceKind { + return PlatformEventKind +} + +func (r *PlatformEventResource) ResourceMesh() string { + return r.Mesh +} + +func (r *PlatformEventResource) ResourceKey() string { + return coremodel.BuildResourceKey(r.Mesh, r.Name) +} + +func (r *PlatformEventResource) ResourceMeta() metav1.ObjectMeta { + return r.ObjectMeta +} + +func (r *PlatformEventResource) ResourceSpec() coremodel.ResourceSpec { + return r.Spec +} + +func (r *PlatformEventResource) DeepCopyObject() k8sruntime.Object { + out := &PlatformEventResource{ + TypeMeta: r.TypeMeta, + Mesh: r.Mesh, + Status: r.Status, + } + r.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + if r.Spec != nil { + specCopy := *r.Spec + out.Spec = &specCopy + } + return out +} + +func (r *PlatformEventResource) String() string { + jsonStr, err := json.Marshal(r) + if err != nil { + logger.Errorf("failed to encode PlatformEventResource: %s to json, err: %v", r.ResourceKey(), err) + return "" + } + return string(jsonStr) +} + +func NewPlatformEventResourceWithAttributes(name string, mesh string) *PlatformEventResource { + return &PlatformEventResource{ + TypeMeta: metav1.TypeMeta{ + Kind: string(PlatformEventKind), + APIVersion: "v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{}, + }, + Mesh: mesh, + Spec: &PlatformEvent{}, + } +} + +func NewPlatformEventResource() coremodel.Resource { + return &PlatformEventResource{ + TypeMeta: metav1.TypeMeta{ + Kind: string(PlatformEventKind), + APIVersion: "v1alpha1", + }, + Spec: &PlatformEvent{}, + } +} + +type PlatformEventResourceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []*PlatformEventResource `json:"items"` +} + +func (r *PlatformEventResourceList) DeepCopyObject() k8sruntime.Object { + out := &PlatformEventResourceList{ + TypeMeta: r.TypeMeta, + } + r.ListMeta.DeepCopyInto(&out.ListMeta) + if len(r.Items) == 0 { + return out + } + out.Items = make([]*PlatformEventResource, len(r.Items)) + for i := range r.Items { + out.Items[i] = r.Items[i].DeepCopyObject().(*PlatformEventResource) + } + return out +} + +func NewPlatformEventResourceList() coremodel.ResourceList { + return &PlatformEventResourceList{ + TypeMeta: metav1.TypeMeta{ + Kind: string(PlatformEventKind), + APIVersion: "v1alpha1", + }, + Items: make([]*PlatformEventResource, 0), + } +} + +func (r *PlatformEventResourceList) SetItems(items []coremodel.Resource) { + r.Items = make([]*PlatformEventResource, len(items)) + for i := range items { + res, ok := items[i].(*PlatformEventResource) + if !ok { + logger.Errorf("unexpected resource type, expected: %s", PlatformEventKind) + continue + } + r.Items[i] = res + } +} diff --git a/pkg/core/store/index/k8s_event.go b/pkg/core/store/index/k8s_event.go new file mode 100644 index 000000000..ab9623326 --- /dev/null +++ b/pkg/core/store/index/k8s_event.go @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package index + +import ( + "reflect" + + "k8s.io/client-go/tools/cache" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" +) + +const ( + ByK8sEventInvolvedObjKind = "idx_k8s_event_involved_obj_kind" + ByK8sEventInvolvedObjName = "idx_k8s_event_involved_obj_name" + ByK8sEventType = "idx_k8s_event_type" + ByK8sEventSource = "idx_k8s_event_source" +) + +func init() { + RegisterIndexers(meshresource.K8sEventKind, map[string]cache.IndexFunc{ + ByK8sEventInvolvedObjKind: byK8sEventInvolvedObjKind, + ByK8sEventInvolvedObjName: byK8sEventInvolvedObjName, + ByK8sEventType: byK8sEventType, + ByK8sEventSource: byK8sEventSource, + }) +} + +func byK8sEventInvolvedObjKind(obj interface{}) ([]string, error) { + event, ok := obj.(*meshresource.K8sEventResource) + if !ok { + return nil, bizerror.NewAssertionError(meshresource.K8sEventKind, reflect.TypeOf(obj).Name()) + } + if event.Spec == nil || event.Spec.InvolvedObjKind == "" { + return []string{}, nil + } + return []string{event.Spec.InvolvedObjKind}, nil +} + +func byK8sEventInvolvedObjName(obj interface{}) ([]string, error) { + event, ok := obj.(*meshresource.K8sEventResource) + if !ok { + return nil, bizerror.NewAssertionError(meshresource.K8sEventKind, reflect.TypeOf(obj).Name()) + } + if event.Spec == nil || event.Spec.InvolvedObjName == "" { + return []string{}, nil + } + return []string{event.Spec.InvolvedObjName}, nil +} + +func byK8sEventType(obj interface{}) ([]string, error) { + event, ok := obj.(*meshresource.K8sEventResource) + if !ok { + return nil, bizerror.NewAssertionError(meshresource.K8sEventKind, reflect.TypeOf(obj).Name()) + } + if event.Spec == nil || event.Spec.Type == "" { + return []string{}, nil + } + return []string{event.Spec.Type}, nil +} + +func byK8sEventSource(obj interface{}) ([]string, error) { + event, ok := obj.(*meshresource.K8sEventResource) + if !ok { + return nil, bizerror.NewAssertionError(meshresource.K8sEventKind, reflect.TypeOf(obj).Name()) + } + if event.Spec == nil || event.Spec.EventSource == "" { + return []string{}, nil + } + return []string{event.Spec.EventSource}, nil +} diff --git a/pkg/core/store/index/platform_event.go b/pkg/core/store/index/platform_event.go new file mode 100644 index 000000000..d9dbd934c --- /dev/null +++ b/pkg/core/store/index/platform_event.go @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package index + +import ( + "reflect" + + "k8s.io/client-go/tools/cache" + + "github.com/apache/dubbo-admin/pkg/common/bizerror" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" +) + +const ( + ByPlatformEventAppName = "idx_platform_event_app_name" + ByPlatformEventInstanceName = "idx_platform_event_instance_name" + ByPlatformEventInstanceIP = "idx_platform_event_instance_ip" + ByPlatformEventServiceName = "idx_platform_event_service_name" + ByPlatformEventSourceType = "idx_platform_event_source_type" +) + +func init() { + RegisterIndexers(meshresource.PlatformEventKind, map[string]cache.IndexFunc{ + ByPlatformEventAppName: byPlatformEventAppName, + ByPlatformEventInstanceName: byPlatformEventInstanceName, + ByPlatformEventInstanceIP: byPlatformEventInstanceIP, + ByPlatformEventServiceName: byPlatformEventServiceName, + ByPlatformEventSourceType: byPlatformEventSourceType, + }) +} + +func byPlatformEventAppName(obj interface{}) ([]string, error) { + event, ok := obj.(*meshresource.PlatformEventResource) + if !ok { + return nil, bizerror.NewAssertionError(meshresource.PlatformEventKind, reflect.TypeOf(obj).Name()) + } + if event.Spec == nil || event.Spec.AppName == "" { + return []string{}, nil + } + return []string{event.Spec.AppName}, nil +} + +func byPlatformEventInstanceName(obj interface{}) ([]string, error) { + event, ok := obj.(*meshresource.PlatformEventResource) + if !ok { + return nil, bizerror.NewAssertionError(meshresource.PlatformEventKind, reflect.TypeOf(obj).Name()) + } + if event.Spec == nil || event.Spec.InstanceName == "" { + return []string{}, nil + } + return []string{event.Spec.InstanceName}, nil +} + +func byPlatformEventInstanceIP(obj interface{}) ([]string, error) { + event, ok := obj.(*meshresource.PlatformEventResource) + if !ok { + return nil, bizerror.NewAssertionError(meshresource.PlatformEventKind, reflect.TypeOf(obj).Name()) + } + if event.Spec == nil || event.Spec.InstanceIP == "" { + return []string{}, nil + } + return []string{event.Spec.InstanceIP}, nil +} + +func byPlatformEventServiceName(obj interface{}) ([]string, error) { + event, ok := obj.(*meshresource.PlatformEventResource) + if !ok { + return nil, bizerror.NewAssertionError(meshresource.PlatformEventKind, reflect.TypeOf(obj).Name()) + } + if event.Spec == nil || event.Spec.ServiceName == "" { + return []string{}, nil + } + return []string{event.Spec.ServiceName}, nil +} + +func byPlatformEventSourceType(obj interface{}) ([]string, error) { + event, ok := obj.(*meshresource.PlatformEventResource) + if !ok { + return nil, bizerror.NewAssertionError(meshresource.PlatformEventKind, reflect.TypeOf(obj).Name()) + } + if event.Spec == nil || event.Spec.SourceType == "" { + return []string{}, nil + } + return []string{event.Spec.SourceType}, nil +} diff --git a/pkg/engine/kubernetes/factory.go b/pkg/engine/kubernetes/factory.go index f1fa79170..652f7e04a 100644 --- a/pkg/engine/kubernetes/factory.go +++ b/pkg/engine/kubernetes/factory.go @@ -72,5 +72,12 @@ func (e *EngineFactory) NewListWatchers(cfg *enginecfg.Config) ([]controller.Res return nil, fmt.Errorf("failed to init PodListerWatcher in kubernetes engine, %w", err) } lwList = append(lwList, podListerWatcher) + + eventListerWatcher, err := listerwatcher.NewK8sEventListWatcher(clientset, cfg) + if err != nil { + return nil, fmt.Errorf("failed to init K8sEventListWatcher in kubernetes engine, %w", err) + } + lwList = append(lwList, eventListerWatcher) + return lwList, nil } diff --git a/pkg/engine/kubernetes/listerwatcher/k8s_event.go b/pkg/engine/kubernetes/listerwatcher/k8s_event.go new file mode 100644 index 000000000..37a1c0e07 --- /dev/null +++ b/pkg/engine/kubernetes/listerwatcher/k8s_event.go @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package listerwatcher + +import ( + "reflect" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + + meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1" + "github.com/apache/dubbo-admin/pkg/common/bizerror" + "github.com/apache/dubbo-admin/pkg/common/constants" + enginecfg "github.com/apache/dubbo-admin/pkg/config/engine" + "github.com/apache/dubbo-admin/pkg/core/controller" + "github.com/apache/dubbo-admin/pkg/core/logger" + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" + coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model" +) + +type K8sEventListerWatcher struct { + cfg *enginecfg.Config + lw cache.ListerWatcher +} + +var _ controller.ResourceListerWatcher = &K8sEventListerWatcher{} + +func NewK8sEventListWatcher(clientset *kubernetes.Clientset, cfg *enginecfg.Config) (*K8sEventListerWatcher, error) { + lw := cache.NewListWatchFromClient( + clientset.CoreV1().RESTClient(), + "events", + metav1.NamespaceAll, + fields.Everything(), + ) + return &K8sEventListerWatcher{cfg: cfg, lw: lw}, nil +} + +func (k *K8sEventListerWatcher) List(options metav1.ListOptions) (k8sruntime.Object, error) { + return k.lw.List(options) +} + +func (k *K8sEventListerWatcher) Watch(options metav1.ListOptions) (watch.Interface, error) { + return k.lw.Watch(options) +} + +func (k *K8sEventListerWatcher) ResourceKind() coremodel.ResourceKind { + return meshresource.K8sEventKind +} + +func (k *K8sEventListerWatcher) TransformFunc() cache.TransformFunc { + return func(obj interface{}) (interface{}, error) { + k8sEvent, ok := obj.(*v1.Event) + if !ok { + return nil, bizerror.NewAssertionError("v1.Event", reflect.TypeOf(obj).Name()) + } + + mesh := constants.DefaultMesh + res := meshresource.NewK8sEventResourceWithAttributes(k8sEvent.Namespace+"/"+k8sEvent.Name, mesh) + res.Spec = &meshproto.K8sEvent{ + Namespace: k8sEvent.Namespace, + Reason: k8sEvent.Reason, + Message: k8sEvent.Message, + Type: k8sEvent.Type, + InvolvedObjKind: k8sEvent.InvolvedObject.Kind, + InvolvedObjName: k8sEvent.InvolvedObject.Name, + SourceComponent: k8sEvent.Source.Component, + SourceHost: k8sEvent.Source.Host, + FirstTimestamp: k8sEvent.FirstTimestamp.Format(constants.TimeFormatStr), + LastTimestamp: k8sEvent.LastTimestamp.Format(constants.TimeFormatStr), + Count: k8sEvent.Count, + EventSource: "KUBERNETES", + } + logger.Debugf("transformed k8s event %s/%s", k8sEvent.Namespace, k8sEvent.Name) + return res, nil + } +} diff --git a/ui-vue3/src/api/service/app.ts b/ui-vue3/src/api/service/app.ts index c38f28ea1..a463ba2d2 100644 --- a/ui-vue3/src/api/service/app.ts +++ b/ui-vue3/src/api/service/app.ts @@ -72,7 +72,12 @@ export const getApplicationTraceDashboard = (params: any): Promise => { params }) } -export const listApplicationEvent = (params: any): Promise => { +export const listApplicationEvent = (params: { + appName?: string + mesh?: string + pageOffset?: number + pageSize?: number +}): Promise => { return request({ url: '/application/event', method: 'get', diff --git a/ui-vue3/src/api/service/instance.ts b/ui-vue3/src/api/service/instance.ts index c2d58ef68..90d540e32 100644 --- a/ui-vue3/src/api/service/instance.ts +++ b/ui-vue3/src/api/service/instance.ts @@ -25,12 +25,18 @@ export const searchInstances = (params: any): Promise => { }) } -export const getInstanceDetail = (params: any): Promise => { +export const getInstanceDetail = ( + params: any, + options?: { + silentError?: boolean + } +): Promise => { return request({ url: '/instance/detail', method: 'get', - params - }) + params, + silentError: options?.silentError + } as any) } export const getInstanceMetricsDashboard = (params: any): Promise => { @@ -108,6 +114,21 @@ export const getInstanceTrafficSwitchAPI = (instanceIP: string, appName: string) * @param appName * @param trafficDisable */ +export const listInstanceEvent = (params: { + instanceName?: string + ip?: string + appName?: string + mesh?: string + pageOffset?: number + pageSize?: number +}): Promise => { + return request({ + url: '/instance/event', + method: 'get', + params + }) +} + export const updateInstanceTrafficSwitchAPI = ( instanceIP: string, appName: string, diff --git a/ui-vue3/src/api/service/service.ts b/ui-vue3/src/api/service/service.ts index f26a4a2a6..99d1c5421 100644 --- a/ui-vue3/src/api/service/service.ts +++ b/ui-vue3/src/api/service/service.ts @@ -145,6 +145,19 @@ export const updateParamRouteAPI = (data: { }) } +export const listServiceEvent = (params: { + serviceName?: string + mesh?: string + pageOffset?: number + pageSize?: number +}): Promise => { + return request({ + url: '/service/event', + method: 'get', + params + }) +} + export const getServiceGraph = (serviceName: string): Promise => { return request({ url: '/service/graph', diff --git a/ui-vue3/src/base/http/request.ts b/ui-vue3/src/base/http/request.ts index 094a76cdd..535a22d1f 100644 --- a/ui-vue3/src/base/http/request.ts +++ b/ui-vue3/src/base/http/request.ts @@ -39,6 +39,10 @@ const isSilentErrorUrl = (url?: string): boolean => { return SILENT_ERROR_URLS.some((silentUrl) => url.includes(silentUrl)) } +const shouldSilenceError = (config?: { url?: string; silentError?: boolean }): boolean => { + return Boolean(config?.silentError) || isSilentErrorUrl(config?.url) +} + const service: AxiosInstance = axios.create({ baseURL: '/api/v1', timeout: 30 * 1000 @@ -82,10 +86,12 @@ response.use( // Show error toast message const errorMsg = `${response.data.code}:${response.data.message}` - if (!isSilentErrorUrl(response.config.url)) { + if (!shouldSilenceError(response.config as any)) { message.error(errorMsg) } - console.error(errorMsg) + if (!shouldSilenceError(response.config as any)) { + console.error(errorMsg) + } return Promise.reject(response.data) }, (error) => { @@ -120,16 +126,20 @@ response.use( } if (response?.data) { const errorMsg = `${response.data?.code}:${response.data?.message}` - if (!isSilentErrorUrl(error.config?.url)) { + if (!shouldSilenceError(error.config as any)) { message.error(errorMsg) } - console.error(errorMsg) + if (!shouldSilenceError(error.config as any)) { + console.error(errorMsg) + } } else { // Handle network or other errors - if (!isSilentErrorUrl(error.config?.url)) { + if (!shouldSilenceError(error.config as any)) { message.error('NetworkError:请求失败,请检查网络连接') } - console.error(error) + if (!shouldSilenceError(error.config as any)) { + console.error(error) + } } return Promise.reject(error.response?.data) } diff --git a/ui-vue3/src/base/i18n/en.ts b/ui-vue3/src/base/i18n/en.ts index 1ae6ec15a..0cf24ce97 100644 --- a/ui-vue3/src/base/i18n/en.ts +++ b/ui-vue3/src/base/i18n/en.ts @@ -562,6 +562,7 @@ const words: I18nType = { distribution: 'Distribution', tracing: 'Tracing', sceneConfig: 'Scene Config', + eventExpiryHint: 'Expired events are not stored', provideService: 'Provide Service', dependentService: 'Dependent Service', diff --git a/ui-vue3/src/base/i18n/zh.ts b/ui-vue3/src/base/i18n/zh.ts index c355a42c9..23dc00eda 100644 --- a/ui-vue3/src/base/i18n/zh.ts +++ b/ui-vue3/src/base/i18n/zh.ts @@ -551,6 +551,7 @@ const words: I18nType = { tracing: '链路追踪', sceneConfig: '场景配置', event: '事件', + eventExpiryHint: '过期事件不会存储', provideService: '提供服务', dependentService: '依赖服务', diff --git a/ui-vue3/src/components/EventTimeline.vue b/ui-vue3/src/components/EventTimeline.vue new file mode 100644 index 000000000..66e889fd9 --- /dev/null +++ b/ui-vue3/src/components/EventTimeline.vue @@ -0,0 +1,215 @@ + + + + + + diff --git a/ui-vue3/src/mocks/handlers/app.ts b/ui-vue3/src/mocks/handlers/app.ts index 484ae9f31..b44bc051b 100644 --- a/ui-vue3/src/mocks/handlers/app.ts +++ b/ui-vue3/src/mocks/handlers/app.ts @@ -146,7 +146,13 @@ export const appHandlers: HttpHandler[] = [ time: '2024-03-31 12:00:00', type: 'deployment-controller' })) - return success({ list }) + const eventList = list.map((item) => ({ + time: item.time, + type: (Math.random() > 0.3 ? 'normal' : 'warning') as 'normal' | 'warning', + message: item.desc, + source: item.type + })) + return success({ list: eventList, total: eventList.length }) }), http.get(`${base}/application/service/form`, () => diff --git a/ui-vue3/src/mocks/handlers/instance.ts b/ui-vue3/src/mocks/handlers/instance.ts index 1cffffd6a..4643aaf09 100644 --- a/ui-vue3/src/mocks/handlers/instance.ts +++ b/ui-vue3/src/mocks/handlers/instance.ts @@ -91,5 +91,25 @@ export const instanceHandlers: HttpHandler[] = [ http.get(`${base}/instance/config/trafficDisable`, () => success({ trafficDisable: false })), - http.put(`${base}/instance/config/trafficDisable`, () => success(null)) + http.put(`${base}/instance/config/trafficDisable`, () => success(null)), + + http.get(`${base}/instance/event`, () => { + const sources = ['deployment-controller', 'nacos', 'replicaset-controller', 'scheduler'] + const messages = [ + 'Scaled down replica set shop-detail-v1-5847b7cdfd to 1 from 2', + 'Scaled up replica set shop-detail-v1-74fd98bc9d to 2 from 1', + 'Successfully assigned shop-user/shop-detail-v1-5847b7cdfd to node hz-ali-30.33.0.1', + 'Created container shop-detail', + 'Started container shop-detail', + 'Pulling image apache/org.apahce.dubbo.samples.shop-user:v1', + 'Instance registered via Nacos: 45.7.37.227:20880' + ] + const list = Array.from({ length: 8 }, (_, i) => ({ + time: `2024/2/17 ${String(20 - i).padStart(2, '0')}:04:38`, + type: (i === 0 ? 'warning' : 'normal') as 'normal' | 'warning', + message: messages[i % messages.length], + source: sources[i % sources.length] + })) + return success({ list, total: list.length }) + }) ] diff --git a/ui-vue3/src/mocks/handlers/service.ts b/ui-vue3/src/mocks/handlers/service.ts index 352fd3a2f..6c7aec983 100644 --- a/ui-vue3/src/mocks/handlers/service.ts +++ b/ui-vue3/src/mocks/handlers/service.ts @@ -253,5 +253,23 @@ export const serviceHandlers: HttpHandler[] = [ http.post(`${base}/service/generic/invoke`, () => success({ elapsedMs: 12, rawResult: { id: '1001', name: 'Alice', age: 18 } }) - ) + ), + + http.get(`${base}/service/event`, () => { + const sources = ['deployment-controller', 'nacos', 'zookeeper', 'kubelet'] + const messages = [ + 'Service provider metadata updated: org.apache.dubbo.samples.UserService:v1', + 'Service consumer registered: shop-user app', + 'Instance registered via Nacos: 10.20.30.11:20880', + 'Condition route rule applied to org.apache.dubbo.samples.UserService', + 'Instance deregistered from Zookeeper: 10.20.30.12:20880' + ] + const list = Array.from({ length: 5 }, (_, i) => ({ + time: `2024/2/17 ${String(20 - i).padStart(2, '0')}:04:38`, + type: (i === 4 ? 'warning' : 'normal') as 'normal' | 'warning', + message: messages[i], + source: sources[i % sources.length] + })) + return success({ list, total: list.length }) + }) ] diff --git a/ui-vue3/src/router/defaultRoutes.ts b/ui-vue3/src/router/defaultRoutes.ts index ecb2c9d4e..ccb2146ac 100644 --- a/ui-vue3/src/router/defaultRoutes.ts +++ b/ui-vue3/src/router/defaultRoutes.ts @@ -169,7 +169,6 @@ export const routes: Readonly = [ component: () => import('../views/resources/applications/tabs/event.vue'), meta: { tab: true, - hidden: true, icon: 'material-symbols:date-range', back: '/resources/applications/list' } @@ -243,7 +242,6 @@ export const routes: Readonly = [ component: () => import('../views/resources/instances/tabs/event.vue'), meta: { tab: true, - hidden: true, icon: 'material-symbols:date-range', back: '/resources/instances/list' } @@ -346,7 +344,6 @@ export const routes: Readonly = [ component: () => import('../views/resources/services/tabs/event.vue'), meta: { tab: true, - hidden: true, back: '/resources/services/list', icon: 'material-symbols:date-range' } diff --git a/ui-vue3/src/types/api.ts b/ui-vue3/src/types/api.ts index 2d4ab1019..e870f2bc9 100644 --- a/ui-vue3/src/types/api.ts +++ b/ui-vue3/src/types/api.ts @@ -115,6 +115,13 @@ export interface ApplicationEventItem { type: string } +export interface EventItem { + time: string + type: 'normal' | 'warning' + message: string + source: string +} + export interface InstanceSearchItem { ip: string name: string diff --git a/ui-vue3/src/views/resources/applications/tabs/event.vue b/ui-vue3/src/views/resources/applications/tabs/event.vue index a91a2d24e..e8f1cbd63 100644 --- a/ui-vue3/src/views/resources/applications/tabs/event.vue +++ b/ui-vue3/src/views/resources/applications/tabs/event.vue @@ -14,143 +14,68 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - +import { ref, onMounted } from 'vue' +import { useRoute } from 'vue-router' +import { useMeshStore } from '@/stores/mesh' +import { listInstanceEvent } from '@/api/service/instance' +import EventTimeline from '@/components/EventTimeline.vue' +import type { EventItem } from '@/types/api' + +const route = useRoute() +const meshStore = useMeshStore() +const defaultPageSize = 20 + +const eventList = ref([]) +const loading = ref(false) +const loadingMore = ref(false) +const hasMore = ref(false) +const pageOffset = ref(0) - +const loadEvents = async (reset = false) => { + if (reset) { + loading.value = true + } else if (loading.value || loadingMore.value || !hasMore.value) { + return + } else { + loadingMore.value = true + } + try { + const instanceName = (route.params.name as string) || '' + const ip = (route.params.pathId as string) || '' + const mesh = meshStore.mesh || 'default' + const currentOffset = reset ? 0 : pageOffset.value + const res = await listInstanceEvent({ + instanceName, + ip, + mesh, + pageOffset: currentOffset, + pageSize: defaultPageSize + }) + const list = res?.data?.list || [] + const total = res?.data?.total || 0 + eventList.value = reset ? list : [...eventList.value, ...list] + pageOffset.value = currentOffset + list.length + hasMore.value = pageOffset.value < total && list.length > 0 + } finally { + loading.value = false + loadingMore.value = false + } +} + +onMounted(async () => { + pageOffset.value = 0 + eventList.value = [] + hasMore.value = true + await loadEvents(true) +}) + diff --git a/ui-vue3/src/views/resources/services/tabs/event.vue b/ui-vue3/src/views/resources/services/tabs/event.vue index d2d41afac..a746529db 100644 --- a/ui-vue3/src/views/resources/services/tabs/event.vue +++ b/ui-vue3/src/views/resources/services/tabs/event.vue @@ -14,66 +14,68 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> + - +const route = useRoute() +const meshStore = useMeshStore() +const defaultPageSize = 20 + +const eventList = ref([]) +const loading = ref(false) +const loadingMore = ref(false) +const hasMore = ref(false) +const pageOffset = ref(0) - + +onMounted(async () => { + pageOffset.value = 0 + eventList.value = [] + hasMore.value = true + await loadEvents(true) +}) +