This is an automated email from the ASF dual-hosted git repository.
robocanic pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-admin.git
The following commit(s) were added to refs/heads/develop by this push:
new 6da958c4 feature: Implement Event stream for
Application/Instance/Service #1472 (#1487)
6da958c4 is described below
commit 6da958c4dd77a5021475dee114e04250b0352fee
Author: Comrade Yi <[email protected]>
AuthorDate: Sun Aug 9 19:38:37 2026 +0800
feature: Implement Event stream for Application/Instance/Service #1472
(#1487)
* 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
* Fix: Address review feedback for K8s event timeline — zero timestamps,
watch spam, silent drops, delete-before-add
- K8sEventListerWatcher: check IsZero() before formatting
FirstTimestamp/LastTimestamp
to avoid emitting synthetic "0001-01-01 00:00:00" values that break
timeline ordering.
- EventTimeline.vue: remove unconditional watch() that triggered cascading
loadMore calls;
rely solely on IntersectionObserver for scroll-based pagination.
- RecordRegistryEvent: log a warning when dropping events due to empty Mesh
or Message
instead of silently discarding them.
- K8sEventSubscriber.writeEvent: delete the informer-written (original-key)
entry only
after a successful Add of the timestamp-prefixed entry, preventing
permanent event
loss when Add fails.
* Refactor: Rename K8sEvent to LifecycleEvent for unified event model
K8sEvent was originally named for K8s-sourced events only, but now also
carries registry-side events (ZK/Nacos) via the EventSource discriminator.
Rename to LifecycleEvent to accurately reflect its role as a unified
lifecycle event type covering both K8s and registry origins.
- Proto: K8sEvent → LifecycleEvent
- Resource: K8sEventResource → LifecycleEventResource
- Kind: K8sEventKind → LifecycleEventKind
- Subscriber: K8sEventSubscriber → LifecycleEventSubscriber
- Index constants: ByK8sEvent* → ByLifecycleEvent*
- Files: k8s_event.* → lifecycle_event.*
- Retained: K8sEventListerWatcher (K8s-specific component)
---
api/mesh/v1alpha1/lifecycle_event.go | 55 +++
api/mesh/v1alpha1/lifecycle_event.proto | 38 ++
pkg/console/handler/event.go | 77 ++++
pkg/console/handler/mesh.go | 14 +-
pkg/console/{handler/mesh.go => model/event.go} | 41 ++-
pkg/console/router/router.go | 3 +
pkg/console/service/event.go | 312 ++++++++++++++++
pkg/core/discovery/component.go | 8 +
pkg/core/discovery/subscriber/lifecycle_event.go | 217 ++++++++++++
pkg/core/discovery/subscriber/nacos_service.go | 197 ++++++++++-
.../discovery/subscriber/registry_event_adapter.go | 126 +++++++
pkg/core/discovery/subscriber/zk_config.go | 44 +++
pkg/core/discovery/subscriber/zk_metadata.go | 35 ++
pkg/core/events/component.go | 2 +-
.../apis/mesh/v1alpha1/lifecycle_event_types.go | 166 +++++++++
pkg/core/store/index/lifecycle_event.go | 87 +++++
pkg/core/store/index/runtime_instance.go | 19 +-
pkg/engine/kubernetes/factory.go | 7 +
pkg/engine/kubernetes/listerwatcher/k8s_event.go | 107 ++++++
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 | 208 +++++++++++
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 +
.../views/resources/applications/tabs/event.vue | 187 +++-------
.../instances/slots/InstanceTabHeaderSlot.vue | 13 +-
.../src/views/resources/instances/tabs/detail.vue | 394 +++++++++++----------
.../src/views/resources/instances/tabs/event.vue | 65 +++-
.../src/views/resources/services/tabs/event.vue | 114 +++---
36 files changed, 2223 insertions(+), 444 deletions(-)
diff --git a/api/mesh/v1alpha1/lifecycle_event.go
b/api/mesh/v1alpha1/lifecycle_event.go
new file mode 100644
index 00000000..d39bee8a
--- /dev/null
+++ b/api/mesh/v1alpha1/lifecycle_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
+
+// LifecycleEvent is the spec for a K8s event resource, capturing both K8s
native events
+// and registry-side lifecycle events in a unified format.
+type LifecycleEvent 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 *LifecycleEvent) Clone() *LifecycleEvent {
+ if e == nil {
+ return nil
+ }
+ return &LifecycleEvent{
+ 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/lifecycle_event.proto
b/api/mesh/v1alpha1/lifecycle_event.proto
new file mode 100644
index 00000000..189389c6
--- /dev/null
+++ b/api/mesh/v1alpha1/lifecycle_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 LifecycleEvent {
+ option (dubbo.mesh.resource).name = "LifecycleEvent";
+ option (dubbo.mesh.resource).plural_name = "LifecycleEvents";
+ 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 00000000..acb546f8
--- /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/handler/mesh.go b/pkg/console/handler/mesh.go
index 227c3427..36091ccf 100644
--- a/pkg/console/handler/mesh.go
+++ b/pkg/console/handler/mesh.go
@@ -24,11 +24,12 @@ import (
"github.com/gin-gonic/gin"
discoverycfg "github.com/apache/dubbo-admin/pkg/config/discovery"
+ enginecfg "github.com/apache/dubbo-admin/pkg/config/engine"
consolectx "github.com/apache/dubbo-admin/pkg/console/context"
"github.com/apache/dubbo-admin/pkg/console/model"
)
-// ListMeshes list all meshes(discoveries) defined in config
+// ListMeshes list all meshes(discoveries + engine) defined in config
func ListMeshes(ctx consolectx.Context) gin.HandlerFunc {
return func(c *gin.Context) {
discoveries := ctx.Config().Discovery
@@ -39,6 +40,17 @@ func ListMeshes(ctx consolectx.Context) gin.HandlerFunc {
Type: string(item.Type),
}
})
+
+ // Include the engine as a mesh so K8s resources are
discoverable in the UI
+ engineCfg := ctx.Config().Engine
+ if engineCfg != nil && engineCfg.Type != enginecfg.Mock {
+ meshes = append(meshes, model.MeshResp{
+ ID: engineCfg.ID,
+ Name: engineCfg.Name,
+ Type: string(engineCfg.Type),
+ })
+ }
+
c.JSON(http.StatusOK, model.NewSuccessResp(meshes))
}
}
diff --git a/pkg/console/handler/mesh.go b/pkg/console/model/event.go
similarity index 53%
copy from pkg/console/handler/mesh.go
copy to pkg/console/model/event.go
index 227c3427..57b76aed 100644
--- a/pkg/console/handler/mesh.go
+++ b/pkg/console/model/event.go
@@ -15,30 +15,29 @@
* limitations under the License.
*/
-package handler
+package model
import (
- "net/http"
+ coremodel "github.com/apache/dubbo-admin/pkg/core/resource/model"
+)
- "github.com/duke-git/lancet/v2/slice"
- "github.com/gin-gonic/gin"
+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
+}
- discoverycfg "github.com/apache/dubbo-admin/pkg/config/discovery"
- consolectx "github.com/apache/dubbo-admin/pkg/console/context"
- "github.com/apache/dubbo-admin/pkg/console/model"
-)
+type EventItem struct {
+ Time string `json:"time"`
+ Type string `json:"type"`
+ Message string `json:"message"`
+ Source string `json:"source"`
+}
-// ListMeshes list all meshes(discoveries) defined in config
-func ListMeshes(ctx consolectx.Context) gin.HandlerFunc {
- return func(c *gin.Context) {
- discoveries := ctx.Config().Discovery
- meshes := slice.Map(discoveries, func(index int, item
*discoverycfg.Config) model.MeshResp {
- return model.MeshResp{
- ID: item.ID,
- Name: item.Name,
- Type: string(item.Type),
- }
- })
- c.JSON(http.StatusOK, model.NewSuccessResp(meshes))
- }
+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 85704c47..846306b6 100644
--- a/pkg/console/router/router.go
+++ b/pkg/console/router/router.go
@@ -50,6 +50,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))
@@ -73,6 +74,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))
}
@@ -107,6 +109,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 00000000..1bae199e
--- /dev/null
+++ b/pkg/console/service/event.go
@@ -0,0 +1,312 @@
+/*
+ * 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 (
+ "strings"
+
+ consolectx "github.com/apache/dubbo-admin/pkg/console/context"
+ "github.com/apache/dubbo-admin/pkg/console/model"
+ "github.com/apache/dubbo-admin/pkg/core/logger"
+ "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"
+)
+
+const defaultPageSize = 20
+
+// ListApplicationEvents returns a paginated event timeline for a Dubbo
application.
+// Resolves pod names from all instances belonging to this app, then matches
+// events by InvolvedObjName against both the pod names and the registry event
+// encoding (appName/...).
+func ListApplicationEvents(ctx consolectx.Context, req *model.EventQueryReq)
(*model.EventListResp, error) {
+ matchNames := resolveAppEventNames(ctx, req)
+
+ allConditions := []index.IndexCondition{
+ {IndexName: index.ByMeshIndex, Value: req.Mesh, Operator:
index.Equals},
+ }
+ resources, err :=
manager.ListByIndexes[*meshresource.LifecycleEventResource](
+ ctx.ResourceManager(),
+ meshresource.LifecycleEventKind,
+ allConditions,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ filtered := filterEventsByNames(resources, matchNames)
+
+ offset := req.PageOffset
+ if offset < 0 {
+ offset = 0
+ }
+ if offset > len(filtered) {
+ offset = len(filtered)
+ }
+ pageSize := req.PageSize
+ if pageSize <= 0 {
+ pageSize = defaultPageSize
+ }
+ end := offset + pageSize
+ if end > len(filtered) {
+ end = len(filtered)
+ }
+ paged := filtered[offset:end]
+
+ return
toEventListResp(&coremodel.PageData[*meshresource.LifecycleEventResource]{
+ Pagination: coremodel.Pagination{
+ Total: len(filtered),
+ PageOffset: req.PageOffset,
+ PageSize: req.PageSize,
+ },
+ Data: paged,
+ }), nil
+}
+
+// resolveAppEventNames returns all InvolvedObjName values that could match
+// events for the given application. It collects pod names from
RuntimeInstances
+// belonging to this app, plus the registry event prefix and the K8s pod name
prefix.
+func resolveAppEventNames(ctx consolectx.Context, req *model.EventQueryReq)
[]string {
+ names := make([]string, 0)
+
+ if req.AppName == "" {
+ return names
+ }
+
+ // Registry event format: appName/... (matched by HasPrefix in filter)
+ names = append(names, req.AppName+"/")
+
+ // K8s Deployment pod names follow {appName}-{hash}-{hash}.
+ // This HasPrefix fallback catches events even after RuntimeInstances
are gone.
+ names = append(names, req.AppName+"-")
+
+ // Find all InstanceResources for this app, then map to pod names via
RuntimeInstance IP.
+ instances, err := manager.ListByIndexes[*meshresource.InstanceResource](
+ ctx.ResourceManager(),
+ meshresource.InstanceKind,
+ []index.IndexCondition{
+ {IndexName: index.ByMeshIndex, Value: req.Mesh,
Operator: index.Equals},
+ {IndexName: index.ByInstanceAppNameIndex, Value:
req.AppName, Operator: index.Equals},
+ },
+ )
+ if err != nil {
+ logger.Warnf("resolveAppEventNames: failed to list instances
for app %s: %v", req.AppName, err)
+ }
+ for _, inst := range instances {
+ if inst.Spec == nil || inst.Spec.Ip == "" {
+ continue
+ }
+ rtResources, err :=
manager.ListByIndexes[*meshresource.RuntimeInstanceResource](
+ ctx.ResourceManager(),
+ meshresource.RuntimeInstanceKind,
+ []index.IndexCondition{
+ {IndexName: index.ByRuntimeInstanceIPIndex,
Value: inst.Spec.Ip, Operator: index.Equals},
+ },
+ )
+ if err != nil {
+ continue
+ }
+ for _, rt := range rtResources {
+ if rt.Spec != nil && rt.Spec.Name != "" {
+ names = append(names, rt.Spec.Name)
+ }
+ }
+ }
+
+ return names
+}
+
+// filterEventsByNames returns events whose InvolvedObjName matches any of the
+// given names, supporting both exact match and prefix match.
+// Prefix candidates end with "/" (registry: appName/...) or "-" (K8s pod:
appName-hash-hash).
+func filterEventsByNames(resources []*meshresource.LifecycleEventResource,
matchNames []string) []*meshresource.LifecycleEventResource {
+ filtered := make([]*meshresource.LifecycleEventResource, 0)
+ for _, r := range resources {
+ if r.Spec == nil {
+ continue
+ }
+ n := r.Spec.InvolvedObjName
+ for _, candidate := range matchNames {
+ if strings.HasSuffix(candidate, "/") ||
strings.HasSuffix(candidate, "-") {
+ if strings.HasPrefix(n, candidate) {
+ filtered = append(filtered, r)
+ break
+ }
+ } else {
+ if n == candidate {
+ filtered = append(filtered, r)
+ break
+ }
+ }
+ }
+ }
+ return filtered
+}
+
+// ListInstanceEvents returns a paginated event timeline for a specific
instance.
+// K8s events use InvolvedObjName = podName; registry events use appName/ip.
+// The frontend sends instanceName = {appName}{IP}:{port}, which doesn't match
+// either format. So we look up the RuntimeInstance by IP to get the pod name,
+// then match events against both the pod name and the IP-based encoding.
+func ListInstanceEvents(ctx consolectx.Context, req *model.EventQueryReq)
(*model.EventListResp, error) {
+ matchNames := resolveInstanceEventNames(ctx, req)
+
+ allConditions := []index.IndexCondition{
+ {IndexName: index.ByMeshIndex, Value: req.Mesh, Operator:
index.Equals},
+ }
+ resources, err :=
manager.ListByIndexes[*meshresource.LifecycleEventResource](
+ ctx.ResourceManager(),
+ meshresource.LifecycleEventKind,
+ allConditions,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ filtered := filterEventsByNames(resources, matchNames)
+
+ offset := req.PageOffset
+ if offset < 0 {
+ offset = 0
+ }
+ if offset > len(filtered) {
+ offset = len(filtered)
+ }
+ pageSize := req.PageSize
+ if pageSize <= 0 {
+ pageSize = defaultPageSize
+ }
+ end := offset + pageSize
+ if end > len(filtered) {
+ end = len(filtered)
+ }
+ paged := filtered[offset:end]
+
+ return
toEventListResp(&coremodel.PageData[*meshresource.LifecycleEventResource]{
+ Pagination: coremodel.Pagination{
+ Total: len(filtered),
+ PageOffset: req.PageOffset,
+ PageSize: req.PageSize,
+ },
+ Data: paged,
+ }), nil
+}
+
+// resolveInstanceEventNames returns the set of InvolvedObjName values that
+// could match this instance's events. For K8s events it's the pod name; for
+// registry events it's appName/ip.
+func resolveInstanceEventNames(ctx consolectx.Context, req
*model.EventQueryReq) []string {
+ names := make([]string, 0, 3)
+
+ // Always include the raw instanceName and IP as candidates (covers
registry
+ // events and the case where the instance name IS the pod name).
+ if req.InstanceName != "" {
+ names = append(names, req.InstanceName)
+ }
+ if req.InstanceIP != "" {
+ names = append(names, req.InstanceIP)
+ // Registry event format: appName/ip
+ if req.AppName != "" {
+ names = append(names, req.AppName+"/"+req.InstanceIP)
+ }
+ }
+
+ // Resolve pod name from RuntimeInstance by IP.
+ if req.InstanceIP != "" {
+ rtResources, err :=
manager.ListByIndexes[*meshresource.RuntimeInstanceResource](
+ ctx.ResourceManager(),
+ meshresource.RuntimeInstanceKind,
+ []index.IndexCondition{
+ {IndexName: index.ByRuntimeInstanceIPIndex,
Value: req.InstanceIP, Operator: index.Equals},
+ },
+ )
+ if err != nil {
+ logger.Warnf("resolveInstanceEventNames: failed to list
RuntimeInstance by IP %s: %v", req.InstanceIP, err)
+ } else {
+ for _, rt := range rtResources {
+ if rt.Spec != nil && rt.Spec.Name != "" {
+ names = append(names, rt.Spec.Name)
+ }
+ }
+ }
+ }
+
+ return names
+}
+
+// ListServiceEvents returns a paginated event timeline for a Dubbo service.
+// Covers registry-side config and metadata events. K8s has no native
+// "service" concept, so K8s-sourced events naturally will not appear here.
+func ListServiceEvents(ctx consolectx.Context, req *model.EventQueryReq)
(*model.EventListResp, error) {
+ conditions := []index.IndexCondition{
+ {IndexName: index.ByMeshIndex, Value: req.Mesh, Operator:
index.Equals},
+ }
+
+ if req.ServiceName != "" && req.AppName != "" {
+ conditions = append(conditions, index.IndexCondition{
+ IndexName: index.ByLifecycleEventInvolvedObjName,
+ Value: req.AppName + "/" + req.ServiceName,
+ Operator: index.HasPrefix,
+ })
+ }
+
+ pageData, err :=
manager.PageListByIndexes[*meshresource.LifecycleEventResource](
+ ctx.ResourceManager(),
+ meshresource.LifecycleEventKind,
+ conditions,
+ req.PageReq,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return toEventListResp(pageData), nil
+}
+
+func toEventListResp(pageData
*coremodel.PageData[*meshresource.LifecycleEventResource]) *model.EventListResp
{
+ items := pageData.Data
+ list := make([]*model.EventItem, 0, len(items))
+ for _, eventRes := range items {
+ if eventRes.Spec == nil {
+ continue
+ }
+
+ eventType := "normal"
+ if strings.EqualFold(eventRes.Spec.Type, "Warning") {
+ eventType = "warning"
+ }
+
+ source := eventRes.Spec.SourceComponent
+ if source == "" {
+ source = eventRes.Spec.EventSource
+ }
+
+ list = append(list, &model.EventItem{
+ Time: eventRes.Spec.LastTimestamp,
+ Type: eventType,
+ Message: eventRes.Spec.Message,
+ Source: source,
+ })
+ }
+
+ return &model.EventListResp{
+ List: list,
+ Total: pageData.Total,
+ }
+}
diff --git a/pkg/core/discovery/component.go b/pkg/core/discovery/component.go
index 0911eab5..09b2004d 100644
--- a/pkg/core/discovery/component.go
+++ b/pkg/core/discovery/component.go
@@ -285,5 +285,13 @@ func (d *discoveryComponent) initSubscribes(storeRouter
store.Router, emitter ev
zkConfigSub := subscriber.NewZKConfigEventSubscriber(emitter,
storeRouter)
d.subscribers = append(d.subscribers, zkMetadataSub,
zkConfigSub)
}
+
+ // LifecycleEventSubscriber processes all LifecycleEvent resources on
the EventBus,
+ // enriching K8s-sourced events and writing them to the store.
+ if engineConfig != nil && engineConfig.Type == engine.Kubernetes {
+ k8sEventSub :=
subscriber.NewLifecycleEventSubscriber(storeRouter, engineConfig)
+ d.subscribers = append(d.subscribers, k8sEventSub)
+ }
+
return nil
}
diff --git a/pkg/core/discovery/subscriber/lifecycle_event.go
b/pkg/core/discovery/subscriber/lifecycle_event.go
new file mode 100644
index 00000000..d923dfb0
--- /dev/null
+++ b/pkg/core/discovery/subscriber/lifecycle_event.go
@@ -0,0 +1,217 @@
+/*
+ * 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"
+ "time"
+
+ "k8s.io/client-go/tools/cache"
+
+ "github.com/apache/dubbo-admin/pkg/common/constants"
+ enginecfg "github.com/apache/dubbo-admin/pkg/config/engine"
+ "github.com/apache/dubbo-admin/pkg/core/events"
+ "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"
+ "github.com/apache/dubbo-admin/pkg/core/store"
+ "github.com/apache/dubbo-admin/pkg/core/store/index"
+)
+
+// LifecycleEventSubscriber processes LifecycleEvent resources on the EventBus.
+// It enriches K8s-sourced events with Dubbo application identification
+// and discards events from non-Dubbo Pods before they reach the store.
+type LifecycleEventSubscriber struct {
+ storeRouter store.Router
+ engineCfg *enginecfg.Config
+}
+
+func NewLifecycleEventSubscriber(storeRouter store.Router, engineCfg
*enginecfg.Config) *LifecycleEventSubscriber {
+ return &LifecycleEventSubscriber{
+ storeRouter: storeRouter,
+ engineCfg: engineCfg,
+ }
+}
+
+func (k *LifecycleEventSubscriber) Name() string {
+ return "Discovery-" + k.ResourceKind().ToString()
+}
+
+func (k *LifecycleEventSubscriber) ResourceKind() coremodel.ResourceKind {
+ return meshresource.LifecycleEventKind
+}
+
+func (k *LifecycleEventSubscriber) AsyncEnabled() bool {
+ return true
+}
+
+func (k *LifecycleEventSubscriber) ProcessEvent(event events.Event) error {
+ switch event.Type() {
+ case cache.Deleted:
+ return k.processDelete(event)
+ default:
+ return k.processUpsert(event)
+ }
+}
+
+func (k *LifecycleEventSubscriber) processUpsert(event events.Event) error {
+ newObj, ok := event.NewObj().(*meshresource.LifecycleEventResource)
+ if !ok || newObj == nil || newObj.Spec == nil {
+ logger.Debugf("LifecycleEventSubscriber: event has no valid
LifecycleEventResource, skipping")
+ return nil
+ }
+
+ // REGISTRY events: already enriched by registry subscriber, write
directly.
+ if newObj.Spec.EventSource == "REGISTRY" {
+ return k.writeEvent(newObj)
+ }
+
+ // KUBERNETES events: filter by Dubbo app identification.
+ identifier := k.engineCfg.Properties.DubboAppIdentifier
+ if identifier == nil {
+ logger.Debugf("LifecycleEventSubscriber: DubboAppIdentifier not
configured, skipping K8s event %s",
+ newObj.Spec.InvolvedObjName)
+ return nil
+ }
+
+ if newObj.Spec.InvolvedObjKind != "Pod" {
+ logger.Debugf("LifecycleEventSubscriber: skipping non-Pod K8s
event (kind=%s)", newObj.Spec.InvolvedObjKind)
+ return nil
+ }
+
+ // Align LifecycleEvent mesh with the RuntimeInstance mesh.
+ // K8sEventListerWatcher cannot know the Pod's Dubbo mesh at transform
time,
+ // so we resolve it here by looking up the corresponding
RuntimeInstance.
+ k.alignMeshFromRuntimeInstance(newObj)
+
+ return k.writeEvent(newObj)
+}
+
+// alignMeshFromRuntimeInstance resolves the correct Dubbo mesh for a
LifecycleEvent
+// by looking up the corresponding RuntimeInstance via the Pod name.
+// RuntimeInstances are stored under their discovery mesh (e.g. "nacos2.5"),
+// not the engine mesh, so we use the name index to search across all meshes.
+func (k *LifecycleEventSubscriber) alignMeshFromRuntimeInstance(eventRes
*meshresource.LifecycleEventResource) {
+ rtStore, err :=
k.storeRouter.ResourceKindRoute(meshresource.RuntimeInstanceKind)
+ if err != nil {
+ logger.Debugf("LifecycleEventSubscriber: cannot route to
RuntimeInstance store: %v", err)
+ return
+ }
+
+ podName := eventRes.Spec.InvolvedObjName
+
+ rtResources, listErr := rtStore.ListByIndexes([]index.IndexCondition{
+ {IndexName: index.ByRuntimeInstanceNameIndex, Value: podName,
Operator: index.Equals},
+ })
+ if listErr != nil {
+ logger.Debugf("LifecycleEventSubscriber: failed to list
RuntimeInstances by name %s: %v", podName, listErr)
+ return
+ }
+
+ for _, rtRes := range rtResources {
+ rtInstance, ok := rtRes.(*meshresource.RuntimeInstanceResource)
+ if !ok || rtInstance == nil {
+ continue
+ }
+ resolvedMesh := rtInstance.ResourceMesh()
+ if resolvedMesh != "" && resolvedMesh != eventRes.Mesh {
+ // Delete the old entry (keyed by old mesh) before
changing mesh.
+ eventStore, _ :=
k.storeRouter.ResourceKindRoute(meshresource.LifecycleEventKind)
+ if eventStore != nil {
+ _ = eventStore.Delete(eventRes)
+ }
+ eventRes.Mesh = resolvedMesh
+ logger.Debugf("LifecycleEventSubscriber: aligned mesh
from %q to %q for pod %s",
+ k.engineCfg.ID, resolvedMesh, podName)
+ return
+ }
+ }
+}
+
+func (k *LifecycleEventSubscriber) processDelete(event events.Event) error {
+ oldObj, ok := event.OldObj().(*meshresource.LifecycleEventResource)
+ if !ok || oldObj == nil {
+ return nil
+ }
+
+ eventStore, err :=
k.storeRouter.ResourceKindRoute(meshresource.LifecycleEventKind)
+ if err != nil {
+ logger.Errorf("LifecycleEventSubscriber: cannot route to
LifecycleEvent store: %v", err)
+ return err
+ }
+
+ if err := eventStore.Delete(oldObj); err != nil {
+ logger.Errorf("LifecycleEventSubscriber: failed to delete
LifecycleEvent %s: %v", oldObj.ResourceKey(), err)
+ }
+ return nil
+}
+
+func (k *LifecycleEventSubscriber) writeEvent(eventRes
*meshresource.LifecycleEventResource) error {
+ eventStore, err :=
k.storeRouter.ResourceKindRoute(meshresource.LifecycleEventKind)
+ if err != nil {
+ logger.Errorf("LifecycleEventSubscriber: cannot route to
LifecycleEvent store: %v", err)
+ return err
+ }
+
+ // Ensure K8s-sourced events have a sortable timestamp prefix in their
key
+ // so that PageListByIndexes (which sorts keys alphabetically) returns
+ // events in chronological order.
+ var oldName string
+ if eventRes.Spec.EventSource == "KUBERNETES" {
+ oldName = eventRes.Name
+ k.prefixTimestampKey(eventRes)
+ }
+
+ if err := eventStore.Add(eventRes); err != nil {
+ logger.Errorf("LifecycleEventSubscriber: failed to add
LifecycleEvent %s: %v", eventRes.ResourceKey(), err)
+ return err
+ }
+
+ // Delete the informer-written entry with the original key only after
the
+ // timestamp-prefixed entry has been successfully added, so that a
failed
+ // Add does not cause permanent event loss.
+ if oldName != "" {
+ oldRes :=
meshresource.NewLifecycleEventResourceWithAttributes(oldName, eventRes.Mesh)
+ _ = eventStore.Delete(oldRes)
+ }
+
+ logger.Infof("LifecycleEventSubscriber: processed event, source=%s,
kind=%s, involved=%s",
+ eventRes.Spec.EventSource, eventRes.Spec.InvolvedObjKind,
eventRes.Spec.InvolvedObjName)
+ return nil
+}
+
+// prefixTimestampKey prepends a descending nano-timestamp to the resource name
+// so that the store's alphabetical key sort produces chronological
(most-recent-first)
+// ordering. Registry events already have this prefix from RecordRegistryEvent.
+// The caller (writeEvent) is responsible for deleting the original key after a
+// successful Add to avoid duplicate entries.
+func (k *LifecycleEventSubscriber) prefixTimestampKey(eventRes
*meshresource.LifecycleEventResource) {
+ timestampNano := int64(0)
+ if eventRes.Spec.LastTimestamp != "" {
+ if t, err := time.Parse(constants.TimeFormatStr,
eventRes.Spec.LastTimestamp); err == nil {
+ timestampNano = t.UnixNano()
+ }
+ }
+ if timestampNano == 0 {
+ timestampNano = time.Now().UnixNano()
+ }
+ // Use descending order: more recent → smaller prefix → sorts first
alphabetically.
+ // maxInt64 - timestampNano gives descending sort.
+ sortablePrefix := fmt.Sprintf("%019d",
int64(^uint64(0)>>1)-timestampNano)
+ eventRes.Name = sortablePrefix + "-" + eventRes.Name
+}
diff --git a/pkg/core/discovery/subscriber/nacos_service.go
b/pkg/core/discovery/subscriber/nacos_service.go
index 4d8495ce..000351b5 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) {
+ RecordRegistryEvent(n.storeRouter, RegistryEventInput{
+ 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)) {
+ RecordRegistryEvent(n.storeRouter, RegistryEventInput{
+ 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
+ }
+ RecordRegistryEvent(n.storeRouter, RegistryEventInput{
+ 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) {
+ RecordRegistryEvent(n.storeRouter, RegistryEventInput{
+ 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)) {
+ RecordRegistryEvent(n.storeRouter, RegistryEventInput{
+ 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
+ }
+ RecordRegistryEvent(n.storeRouter, RegistryEventInput{
+ 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/registry_event_adapter.go
b/pkg/core/discovery/subscriber/registry_event_adapter.go
new file mode 100644
index 00000000..ddec9db5
--- /dev/null
+++ b/pkg/core/discovery/subscriber/registry_event_adapter.go
@@ -0,0 +1,126 @@
+/*
+ * 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"
+
+ meshproto "github.com/apache/dubbo-admin/api/mesh/v1alpha1"
+ "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 registryEventNameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
+
+// RegistryEventInput carries the data needed to record a registry-side event
+// as a unified LifecycleEventResource.
+type RegistryEventInput struct {
+ Mesh string
+ Source string // human-readable source name (e.g. "Nacos",
"Zookeeper")
+ SourceType string // machine identifier (e.g. "nacos", "zookeeper")
+ Category string // "registry" | "config" | "metadata"
+ Action string // "registered" | "deregistered" | "updated" |
"added" | "deleted"
+ Message string
+ Type string // "normal" | "warning"
+ AppName string
+ InstanceName string
+ InstanceIP string
+ ServiceName string
+}
+
+// RecordRegistryEvent writes a registry-side lifecycle event as a unified
+// LifecycleEventResource. The InvolvedObjName encodes a hierarchical
identifier
+// (appName/ip:port for instances, appName/serviceName for config/metadata)
+// so that Console API queries can use a single HasPrefix lookup.
+func RecordRegistryEvent(storeRouter store.Router, input RegistryEventInput) {
+ if input.Mesh == "" || input.Message == "" {
+ logger.Warnf("RecordRegistryEvent: dropping event due to empty
Mesh or Message, source=%s, category=%s, action=%s, appName=%s",
+ input.SourceType, input.Category, input.Action,
input.AppName)
+ return
+ }
+ eventStore, err :=
storeRouter.ResourceKindRoute(meshresource.LifecycleEventKind)
+ if err != nil {
+ logger.Errorf("route LifecycleEvent store failed, cause: %v",
err)
+ return
+ }
+
+ now := time.Now()
+
+ // Build InvolvedObjName with hierarchical encoding:
+ // - instance events: {appName}/{ip}:{port}
+ // - config events: {appName}/{serviceName}:{ruleType}
+ // - metadata events: {appName}/{serviceName}
+ var involvedObjName string
+ switch {
+ case input.InstanceIP != "":
+ involvedObjName = input.AppName + "/" + input.InstanceIP
+ case input.ServiceName != "":
+ involvedObjName = input.AppName + "/" + input.ServiceName
+ default:
+ involvedObjName = input.AppName
+ }
+
+ nameSeed := strings.Join([]string{
+ input.SourceType,
+ input.Category,
+ input.Action,
+ input.AppName,
+ input.ServiceName,
+ input.InstanceName,
+ input.InstanceIP,
+ }, "-")
+ sanitized := registryEventNameSanitizer.ReplaceAllString(nameSeed, "-")
+ sanitized = strings.Trim(sanitized, "-")
+ if sanitized == "" {
+ sanitized = "event"
+ }
+ eventName := fmt.Sprintf("%d-%s", now.UnixNano(), sanitized)
+
+ eventType := "normal"
+ if strings.EqualFold(input.Type, "warning") {
+ eventType = "warning"
+ }
+
+ source := input.Source
+ if source == "" {
+ source = input.SourceType
+ }
+
+ res := meshresource.NewLifecycleEventResourceWithAttributes(eventName,
input.Mesh)
+ res.Spec = &meshproto.LifecycleEvent{
+ InvolvedObjKind: input.Category,
+ InvolvedObjName: involvedObjName,
+ Reason: input.Action,
+ Message: input.Message,
+ Type: eventType,
+ SourceComponent: input.SourceType,
+ SourceHost: input.InstanceIP,
+ EventSource: "REGISTRY",
+ LastTimestamp: now.Format(constants.TimeFormatStr),
+ FirstTimestamp: now.Format(constants.TimeFormatStr),
+ }
+
+ if err := eventStore.Add(res); err != nil {
+ logger.Errorf("record registry event failed, key: %s, cause:
%v", res.ResourceKey(), err)
+ }
+}
diff --git a/pkg/core/discovery/subscriber/zk_config.go
b/pkg/core/discovery/subscriber/zk_config.go
index f9e0eec1..f4173db4 100644
--- a/pkg/core/discovery/subscriber/zk_config.go
+++ b/pkg/core/discovery/subscriber/zk_config.go
@@ -174,6 +174,8 @@ 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))
emitter.Send(events.NewResourceChangedEventWithContext(cache.Added, nil,
newRuleRes, map[string]string{
events.SourceRegistryContextKey:
sourceRegistryZookeeper,
}))
@@ -196,6 +198,8 @@ func processConfigUpsert[T coremodel.Resource](
emitter.Send(events.NewResourceChangedEventWithContext(cache.Updated,
oldMetadataRes, newRuleRes, map[string]string{
events.SourceRegistryContextKey: sourceRegistryZookeeper,
}))
+ recordConfigPlatformEvent(router, newRuleRes, "updated")
+ emitter.Send(events.NewResourceChangedEvent(cache.Updated,
oldMetadataRes, newRuleRes))
return nil
}
@@ -228,8 +232,48 @@ func processConfigDelete[T coremodel.Resource](
logger.Errorf("delete rule %s from store failed, cause: %s",
resourceKey, err.Error())
return err
}
+ recordConfigPlatformEvent(router, oldRuleRes, "deleted")
+ emitter.Send(events.NewResourceChangedEvent(cache.Deleted, oldRuleRes,
nil))
emitter.Send(events.NewResourceChangedEventWithContext(cache.Deleted,
oldRuleRes, nil, map[string]string{
events.SourceRegistryContextKey: sourceRegistryZookeeper,
}))
return nil
}
+
+func recordConfigPlatformEvent(router store.Router, res coremodel.Resource,
action string) {
+ serviceName, category := extractRuleEventContext(res)
+ if serviceName == "" {
+ return
+ }
+ RecordRegistryEvent(router, RegistryEventInput{
+ 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 4a744119..93c91d87 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
+ }
+ RecordRegistryEvent(router, RegistryEventInput{
+ 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
+ }
+ RecordRegistryEvent(router, RegistryEventInput{
+ 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 7592dbaa..f5fbf6fe 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/lifecycle_event_types.go
b/pkg/core/resource/apis/mesh/v1alpha1/lifecycle_event_types.go
new file mode 100644
index 00000000..6a8eba6a
--- /dev/null
+++ b/pkg/core/resource/apis/mesh/v1alpha1/lifecycle_event_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 LifecycleEventKind coremodel.ResourceKind = "LifecycleEvent"
+
+func init() {
+ coremodel.RegisterResourceSchema(LifecycleEventKind,
NewLifecycleEventResource, NewLifecycleEventResourceList)
+}
+
+type LifecycleEventResource 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 LifecycleEvent resource.
+ Spec *meshproto.LifecycleEvent `json:"spec,omitempty"`
+
+ // Status is the status of the LifecycleEvent resource.
+ Status LifecycleEventResourceStatus `json:"status,omitempty"`
+}
+
+type LifecycleEventResourceStatus struct{}
+
+func (r *LifecycleEventResource) ResourceKind() coremodel.ResourceKind {
+ return LifecycleEventKind
+}
+
+func (r *LifecycleEventResource) ResourceMesh() string {
+ return r.Mesh
+}
+
+func (r *LifecycleEventResource) ResourceKey() string {
+ return coremodel.BuildResourceKey(r.Mesh, r.Name)
+}
+
+func (r *LifecycleEventResource) ResourceMeta() metav1.ObjectMeta {
+ return r.ObjectMeta
+}
+
+func (r *LifecycleEventResource) ResourceSpec() coremodel.ResourceSpec {
+ return r.Spec
+}
+
+func (r *LifecycleEventResource) DeepCopyObject() k8sruntime.Object {
+ out := &LifecycleEventResource{
+ 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 *LifecycleEventResource) String() string {
+ jsonStr, err := json.Marshal(r)
+ if err != nil {
+ logger.Errorf("failed to encode LifecycleEventResource: %s to
json, err: %v", r.ResourceKey(), err)
+ return ""
+ }
+ return string(jsonStr)
+}
+
+func NewLifecycleEventResourceWithAttributes(name string, mesh string)
*LifecycleEventResource {
+ return &LifecycleEventResource{
+ TypeMeta: metav1.TypeMeta{
+ Kind: string(LifecycleEventKind),
+ APIVersion: "v1alpha1",
+ },
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Labels: map[string]string{},
+ },
+ Mesh: mesh,
+ Spec: &meshproto.LifecycleEvent{},
+ }
+}
+
+func NewLifecycleEventResource() coremodel.Resource {
+ return &LifecycleEventResource{
+ TypeMeta: metav1.TypeMeta{
+ Kind: string(LifecycleEventKind),
+ APIVersion: "v1alpha1",
+ },
+ Spec: &meshproto.LifecycleEvent{},
+ }
+}
+
+type LifecycleEventResourceList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []*LifecycleEventResource `json:"items"`
+}
+
+func (r *LifecycleEventResourceList) DeepCopyObject() k8sruntime.Object {
+ out := &LifecycleEventResourceList{
+ TypeMeta: r.TypeMeta,
+ }
+ r.ListMeta.DeepCopyInto(&out.ListMeta)
+
+ if len(r.Items) == 0 {
+ return out
+ }
+ out.Items = make([]*LifecycleEventResource, len(r.Items))
+ for i := range r.Items {
+ out.Items[i] =
r.Items[i].DeepCopyObject().(*LifecycleEventResource)
+ }
+ return out
+}
+
+func NewLifecycleEventResourceList() coremodel.ResourceList {
+ return &LifecycleEventResourceList{
+ TypeMeta: metav1.TypeMeta{
+ Kind: string(LifecycleEventKind),
+ APIVersion: "v1alpha1",
+ },
+ Items: make([]*LifecycleEventResource, 0),
+ }
+}
+
+func (r *LifecycleEventResourceList) SetItems(items []coremodel.Resource) {
+ r.Items = make([]*LifecycleEventResource, len(items))
+ for i := range items {
+ res, ok := items[i].(*LifecycleEventResource)
+ if !ok {
+ logger.Errorf("unexpected resource type in
LifecycleEventResourceList.SetItems: expected %T, got %T",
(*LifecycleEventResource)(nil), items[i])
+ continue
+ }
+ r.Items[i] = res
+ }
+}
diff --git a/pkg/core/store/index/lifecycle_event.go
b/pkg/core/store/index/lifecycle_event.go
new file mode 100644
index 00000000..0fa44619
--- /dev/null
+++ b/pkg/core/store/index/lifecycle_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 (
+ ByLifecycleEventInvolvedObjKind =
"idx_lifecycle_event_involved_obj_kind"
+ ByLifecycleEventInvolvedObjName =
"idx_lifecycle_event_involved_obj_name"
+ ByLifecycleEventType = "idx_lifecycle_event_type"
+ ByLifecycleEventSource = "idx_lifecycle_event_source"
+)
+
+func init() {
+ RegisterIndexers(meshresource.LifecycleEventKind,
map[string]cache.IndexFunc{
+ ByLifecycleEventInvolvedObjKind:
byLifecycleEventInvolvedObjKind,
+ ByLifecycleEventInvolvedObjName:
byLifecycleEventInvolvedObjName,
+ ByLifecycleEventType: byLifecycleEventType,
+ ByLifecycleEventSource: byLifecycleEventSource,
+ })
+}
+
+func byLifecycleEventInvolvedObjKind(obj interface{}) ([]string, error) {
+ event, ok := obj.(*meshresource.LifecycleEventResource)
+ if !ok {
+ return nil,
bizerror.NewAssertionError(meshresource.LifecycleEventKind,
reflect.TypeOf(obj).Name())
+ }
+ if event.Spec == nil || event.Spec.InvolvedObjKind == "" {
+ return []string{}, nil
+ }
+ return []string{event.Spec.InvolvedObjKind}, nil
+}
+
+func byLifecycleEventInvolvedObjName(obj interface{}) ([]string, error) {
+ event, ok := obj.(*meshresource.LifecycleEventResource)
+ if !ok {
+ return nil,
bizerror.NewAssertionError(meshresource.LifecycleEventKind,
reflect.TypeOf(obj).Name())
+ }
+ if event.Spec == nil || event.Spec.InvolvedObjName == "" {
+ return []string{}, nil
+ }
+ return []string{event.Spec.InvolvedObjName}, nil
+}
+
+func byLifecycleEventType(obj interface{}) ([]string, error) {
+ event, ok := obj.(*meshresource.LifecycleEventResource)
+ if !ok {
+ return nil,
bizerror.NewAssertionError(meshresource.LifecycleEventKind,
reflect.TypeOf(obj).Name())
+ }
+ if event.Spec == nil || event.Spec.Type == "" {
+ return []string{}, nil
+ }
+ return []string{event.Spec.Type}, nil
+}
+
+func byLifecycleEventSource(obj interface{}) ([]string, error) {
+ event, ok := obj.(*meshresource.LifecycleEventResource)
+ if !ok {
+ return nil,
bizerror.NewAssertionError(meshresource.LifecycleEventKind,
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/runtime_instance.go
b/pkg/core/store/index/runtime_instance.go
index 745e8114..408c076c 100644
--- a/pkg/core/store/index/runtime_instance.go
+++ b/pkg/core/store/index/runtime_instance.go
@@ -27,11 +27,15 @@ import (
meshresource
"github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1"
)
-const ByRuntimeInstanceIPIndex = "idx_rt_instance_ip"
+const (
+ ByRuntimeInstanceIPIndex = "idx_rt_instance_ip"
+ ByRuntimeInstanceNameIndex = "idx_rt_instance_name"
+)
func init() {
RegisterIndexers(meshresource.RuntimeInstanceKind,
map[string]cache.IndexFunc{
- ByRuntimeInstanceIPIndex: byRuntimeInstanceIp,
+ ByRuntimeInstanceIPIndex: byRuntimeInstanceIp,
+ ByRuntimeInstanceNameIndex: byRuntimeInstanceName,
})
}
@@ -45,3 +49,14 @@ func byRuntimeInstanceIp(obj interface{}) ([]string, error) {
}
return []string{rtInstance.Spec.Ip}, nil
}
+
+func byRuntimeInstanceName(obj interface{}) ([]string, error) {
+ rtInstance, ok := obj.(*meshresource.RuntimeInstanceResource)
+ if !ok {
+ return nil,
bizerror.NewAssertionError(meshresource.RuntimeInstanceKind,
reflect.TypeOf(obj).Name())
+ }
+ if rtInstance.Spec == nil || rtInstance.Spec.Name == "" {
+ return []string{}, nil
+ }
+ return []string{rtInstance.Spec.Name}, nil
+}
diff --git a/pkg/engine/kubernetes/factory.go b/pkg/engine/kubernetes/factory.go
index f1fa7917..652f7e04 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 00000000..fef91138
--- /dev/null
+++ b/pkg/engine/kubernetes/listerwatcher/k8s_event.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 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) {
+ // Only watch events related to Pods to avoid collecting cluster-wide
noise
+ // (Node events, Namespace events, etc.). The LifecycleEventSubscriber
on the
+ // EventBus performs further filtering using DubboAppIdentifier.
+ lw := cache.NewListWatchFromClient(
+ clientset.CoreV1().RESTClient(),
+ "events",
+ metav1.NamespaceAll,
+ fields.ParseSelectorOrDie("involvedObject.kind=Pod"),
+ )
+ 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.LifecycleEventKind
+}
+
+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())
+ }
+
+ firstTs := ""
+ if !k8sEvent.FirstTimestamp.IsZero() {
+ firstTs =
k8sEvent.FirstTimestamp.Format(constants.TimeFormatStr)
+ }
+ lastTs := ""
+ if !k8sEvent.LastTimestamp.IsZero() {
+ lastTs =
k8sEvent.LastTimestamp.Format(constants.TimeFormatStr)
+ }
+
+ res :=
meshresource.NewLifecycleEventResourceWithAttributes(k8sEvent.Namespace+"/"+k8sEvent.Name,
k.cfg.ID)
+ res.Spec = &meshproto.LifecycleEvent{
+ 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: firstTs,
+ LastTimestamp: lastTs,
+ 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 c38f28ea..a463ba2d 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<any> => {
params
})
}
-export const listApplicationEvent = (params: any): Promise<any> => {
+export const listApplicationEvent = (params: {
+ appName?: string
+ mesh?: string
+ pageOffset?: number
+ pageSize?: number
+}): Promise<any> => {
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 c2d58ef6..90d540e3 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<any>
=> {
})
}
-export const getInstanceDetail = (params: any): Promise<any> => {
+export const getInstanceDetail = (
+ params: any,
+ options?: {
+ silentError?: boolean
+ }
+): Promise<any> => {
return request({
url: '/instance/detail',
method: 'get',
- params
- })
+ params,
+ silentError: options?.silentError
+ } as any)
}
export const getInstanceMetricsDashboard = (params: any): Promise<any> => {
@@ -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<any> => {
+ 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 f26a4a2a..99d1c542 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<any> => {
+ return request({
+ url: '/service/event',
+ method: 'get',
+ params
+ })
+}
+
export const getServiceGraph = (serviceName: string): Promise<any> => {
return request({
url: '/service/graph',
diff --git a/ui-vue3/src/base/http/request.ts b/ui-vue3/src/base/http/request.ts
index bc5956ec..535a22d1 100644
--- a/ui-vue3/src/base/http/request.ts
+++ b/ui-vue3/src/base/http/request.ts
@@ -39,8 +39,8 @@ const isSilentErrorUrl = (url?: string): boolean => {
return SILENT_ERROR_URLS.some((silentUrl) => url.includes(silentUrl))
}
-const shouldShowErrorMessage = (url?: string): boolean => {
- return !isSilentErrorUrl(url)
+const shouldSilenceError = (config?: { url?: string; silentError?: boolean }):
boolean => {
+ return Boolean(config?.silentError) || isSilentErrorUrl(config?.url)
}
const service: AxiosInstance = axios.create({
@@ -86,10 +86,12 @@ response.use(
// Show error toast message
const errorMsg = `${response.data.code}:${response.data.message}`
- if (shouldShowErrorMessage(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) => {
@@ -124,16 +126,20 @@ response.use(
}
if (response?.data) {
const errorMsg = `${response.data?.code}:${response.data?.message}`
- if (shouldShowErrorMessage(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 ebdf3443..169aac1c 100644
--- a/ui-vue3/src/base/i18n/en.ts
+++ b/ui-vue3/src/base/i18n/en.ts
@@ -600,6 +600,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 776cf595..557a5401 100644
--- a/ui-vue3/src/base/i18n/zh.ts
+++ b/ui-vue3/src/base/i18n/zh.ts
@@ -589,6 +589,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 00000000..4e1d15a8
--- /dev/null
+++ b/ui-vue3/src/components/EventTimeline.vue
@@ -0,0 +1,208 @@
+<!--
+ ~ 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.
+-->
+<template>
+ <div class="event-timeline-container">
+ <a-spin :spinning="loading">
+ <a-timeline mode="left" class="event-timeline">
+ <a-timeline-item
+ v-for="(item, index) in events"
+ :key="index"
+ :color="item.type === 'warning' ? '#faad14' : '#1890ff'"
+ >
+ <!-- Time label on the left -->
+ <template #label>
+ <span class="event-time">{{ item.time }}</span>
+ </template>
+
+ <!-- Custom dot -->
+ <template #dot>
+ <div class="event-dot" :class="item.type">
+ <CheckCircleOutlined v-if="item.type !== 'warning'"
class="dot-icon normal" />
+ <WarningOutlined v-else class="dot-icon warning" />
+ </div>
+ </template>
+
+ <!-- Event card -->
+ <div class="event-card" :class="item.type">
+ <span class="event-message">{{ item.message }}</span>
+ <a-tag :color="item.type === 'warning' ? 'orange' : 'blue'"
class="event-source-tag">
+ {{ item.source }}
+ </a-tag>
+ </div>
+ </a-timeline-item>
+
+ <!-- Bottom hint -->
+ <a-timeline-item>
+ <template #dot>
+ <ArrowDownOutlined class="bottom-arrow" />
+ </template>
+ <div class="bottom-hint">
+ <span>{{ $t('eventExpiryHint') || '过期事件不会存储' }}</span>
+ <a-spin v-if="loadingMore" size="small" class="load-more-spinner"
/>
+ </div>
+ </a-timeline-item>
+ </a-timeline>
+ <div ref="loadMoreTriggerRef" class="load-more-trigger" />
+ </a-spin>
+
+ <a-empty v-if="!loading && events.length === 0" description="暂无事件" />
+ </div>
+</template>
+
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref } from 'vue'
+import type { EventItem } from '@/types/api'
+import { CheckCircleOutlined, WarningOutlined, ArrowDownOutlined } from
'@ant-design/icons-vue'
+
+const props = defineProps<{
+ events: EventItem[]
+ loading: boolean
+ loadingMore?: boolean
+ hasMore?: boolean
+}>()
+
+const emit = defineEmits<{
+ (e: 'loadMore'): void
+}>()
+
+const loadMoreTriggerRef = ref<HTMLElement>()
+let observer: IntersectionObserver | null = null
+
+const tryLoadMore = () => {
+ if (!props.loading && !props.loadingMore && props.hasMore) {
+ emit('loadMore')
+ }
+}
+
+onMounted(() => {
+ if (!window.IntersectionObserver || !loadMoreTriggerRef.value) {
+ return
+ }
+ observer = new IntersectionObserver((entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ tryLoadMore()
+ }
+ })
+ observer.observe(loadMoreTriggerRef.value)
+})
+
+onBeforeUnmount(() => {
+ observer?.disconnect()
+ observer = null
+})
+</script>
+
+<style lang="less" scoped>
+.event-timeline-container {
+ padding: 40px 20px 20px;
+
+ .event-timeline {
+ :deep(.ant-timeline-item-label) {
+ width: 180px;
+ }
+
+ :deep(.ant-timeline-item-tail) {
+ border-left: 2px solid #e8f4ff;
+ }
+ }
+
+ .event-time {
+ font-size: 13px;
+ color: #8c8c8c;
+ white-space: nowrap;
+ display: inline-block;
+ width: 160px;
+ text-align: right;
+ }
+
+ .event-dot {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .dot-icon {
+ font-size: 16px;
+
+ &.normal {
+ color: #1890ff;
+ }
+
+ &.warning {
+ color: #faad14;
+ }
+ }
+ }
+
+ .event-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-radius: 6px;
+ border-left: 4px solid #1890ff;
+ background: #fff;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+ transition: box-shadow 0.2s;
+
+ &:hover {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
+ }
+
+ &.warning {
+ border-left-color: #faad14;
+ background: #fff7e6;
+ }
+
+ .event-message {
+ flex: 1;
+ font-size: 14px;
+ color: #262626;
+ margin-right: 12px;
+ line-height: 1.5;
+ }
+
+ .event-source-tag {
+ flex-shrink: 0;
+ font-size: 12px;
+ border-radius: 4px;
+ }
+ }
+
+ .bottom-arrow {
+ font-size: 14px;
+ color: #bfbfbf;
+ }
+
+ .bottom-hint {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: #bfbfbf;
+ padding: 8px 0;
+ }
+
+ .load-more-spinner {
+ flex-shrink: 0;
+ }
+
+ .load-more-trigger {
+ width: 100%;
+ height: 1px;
+ }
+}
+</style>
diff --git a/ui-vue3/src/mocks/handlers/app.ts
b/ui-vue3/src/mocks/handlers/app.ts
index 484ae9f3..b44bc051 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 1cffffd6..4643aaf0 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 352fd3a2..6c7aec98 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 ecb2c9d4..ccb2146a 100644
--- a/ui-vue3/src/router/defaultRoutes.ts
+++ b/ui-vue3/src/router/defaultRoutes.ts
@@ -169,7 +169,6 @@ export const routes: Readonly<RouteRecordType[]> = [
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<RouteRecordType[]> = [
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<RouteRecordType[]> = [
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 2d4ab101..e870f2bc 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 a91a2d24..e8f1cbd6 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.
-->
-<template>
- <div class="__container_app_event">
- <a-timeline mode="left">
- <a-timeline-item v-for="(item, i) in events.list">
- <div class="box">
- <div class="label" :class="{ yellow: i === 0 }">
- <div class="type"></div>
- <div class="body">
- <b class="title">{{ item.type }}</b>
- <p>{{ item.desc }}</p>
- </div>
- </div>
- <span class="time">
- {{ item.time }}
- </span>
- </div>
- <template v-if="i === 0" #dot>
- <clock-circle-outlined style="font-size: 16px; color: red" />
- </template>
- <!-- <a-card>-->
- <!-- <a-row>-->
- <!-- <a-col :span="4">-->
- <!--<!– <div class="box">–>-->
- <!--<!– <div class="type"></div>–>-->
- <!--<!– <div class="body"></div>–>-->
- <!--<!– </div>–>-->
- <!-- </a-col>-->
- <!-- <a-col :span="10">{{item.desc}}</a-col>-->
- <!-- </a-row>-->
- <!-- </a-card>-->
- </a-timeline-item>
- </a-timeline>
- </div>
+<template>
+ <EventTimeline
+ :events="eventList"
+ :loading="loading"
+ :loadingMore="loadingMore"
+ :hasMore="hasMore"
+ @loadMore="loadEvents()"
+ />
</template>
-<script setup lang="ts">
-import { onMounted, reactive } from 'vue'
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { useMeshStore } from '@/stores/mesh'
import { listApplicationEvent } from '@/api/service/app'
-import { ClockCircleOutlined } from '@ant-design/icons-vue'
-import { PRIMARY_COLOR } from '@/base/constants'
-
-let __ = PRIMARY_COLOR
-let events: any = reactive({ list: [] })
-onMounted(async () => {
- let eventsRes = await listApplicationEvent({})
- events.list = eventsRes.data.list
- console.log(events)
-})
-</script>
-<style lang="less" scoped>
-.__container_app_event {
- :deep(.ant-timeline-item-label) {
- width: 200px;
- }
+import EventTimeline from '@/components/EventTimeline.vue'
+import type { EventItem } from '@/types/api'
- background: #fafafa;
- border-radius: 10px;
- padding: 80px 300px 20px;
+const route = useRoute()
+const meshStore = useMeshStore()
+const defaultPageSize = 20
- .box {
- position: relative;
- height: 100px;
- margin-bottom: 20px;
- //top:-38px;
+const eventList = ref<EventItem[]>([])
+const loading = ref(false)
+const loadingMore = ref(false)
+const hasMore = ref(false)
+const pageOffset = ref(0)
- .label {
- position: absolute;
- height: 100px;
- top: -40px;
-
- &.yellow {
- .type {
- border-right-color: #f8d347;
- }
- .body {
- background: #f8d347;
- }
- }
- &.red {
- .type {
- border-right-color: #eb4325;
- }
- .body {
- background: #eb4325;
- }
- }
- &.blue {
- .type {
- border-right-color: #3d89f6;
- }
- .body {
- background: #3d89f6;
- }
- }
- &.green {
- .type {
- border-right-color: #9cac35;
- }
- .body {
- background: #9cac35;
- }
- }
- .type {
- position: absolute;
- width: 50px;
- height: 50px;
- border-style: solid;
- border-color: transparent;
- border-width: 50px 26px 50px 0px;
- border-right-color: v-bind('PRIMARY_COLOR');
- display: inline;
- border-radius: 4px;
- }
- .body {
- position: absolute;
- left: 49px;
- width: 50vw;
- border-radius: 3px 5px 5px 3px;
- height: 100%;
- display: inline;
- color: white;
- padding-left: 20px;
- background: v-bind('PRIMARY_COLOR');
- box-shadow: 8px 5px 10px #9f9c9c;
-
- .title {
- font-size: 30px;
- line-height: 40px;
- }
- }
- }
- .time {
- position: absolute;
- left: -200px;
- //top: 38px;
- }
+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 appName = (route.params.pathId as string) || ''
+ const mesh = meshStore.mesh || 'default'
+ const currentOffset = reset ? 0 : pageOffset.value
+ const res = await listApplicationEvent({
+ appName,
+ 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
}
}
-</style>
+
+onMounted(async () => {
+ pageOffset.value = 0
+ eventList.value = []
+ hasMore.value = true
+ await loadEvents(true)
+})
+</script>
diff --git
a/ui-vue3/src/views/resources/instances/slots/InstanceTabHeaderSlot.vue
b/ui-vue3/src/views/resources/instances/slots/InstanceTabHeaderSlot.vue
index cf21a233..a58a8514 100644
--- a/ui-vue3/src/views/resources/instances/slots/InstanceTabHeaderSlot.vue
+++ b/ui-vue3/src/views/resources/instances/slots/InstanceTabHeaderSlot.vue
@@ -51,10 +51,15 @@ const fetchInstanceLifecycleState = async () => {
}
try {
- const { data } = await getInstanceDetail({
- instanceName,
- instanceIP: route.params?.pathId
- })
+ const { data } = await getInstanceDetail(
+ {
+ instanceName,
+ instanceIP: route.params?.pathId
+ },
+ {
+ silentError: true
+ }
+ )
instanceLifecycleState.value = data?.lifecycleState || 'Unknown'
} catch {
instanceLifecycleState.value = 'Unknown'
diff --git a/ui-vue3/src/views/resources/instances/tabs/detail.vue
b/ui-vue3/src/views/resources/instances/tabs/detail.vue
index 495a359b..235876a8 100644
--- a/ui-vue3/src/views/resources/instances/tabs/detail.vue
+++ b/ui-vue3/src/views/resources/instances/tabs/detail.vue
@@ -17,227 +17,230 @@
<template>
<div class="__container_instance_detail">
+ <a-empty v-if="instanceMissing" description="实例不存在或已下线" />
<a-flex>
- <a-card-grid>
- <a-row :gutter="10">
- <a-col :span="12">
- <a-card class="_detail" style="height: 100%">
- <a-descriptions class="description-column" :column="1">
- <!-- registerState -->
- <a-descriptions-item
- :label="$t('instanceDomain.registerState')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-typography-paragraph
- :type="instanceDetail?.registerState === 'Registered' ?
'success' : 'danger'"
+ <template v-if="!instanceMissing">
+ <a-card-grid>
+ <a-row :gutter="10">
+ <a-col :span="12">
+ <a-card class="_detail" style="height: 100%">
+ <a-descriptions class="description-column" :column="1">
+ <!-- registerState -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.registerState')"
+ :labelStyle="{ fontWeight: 'bold' }"
>
- {{ instanceDetail?.registerState }}
- </a-typography-paragraph>
- </a-descriptions-item>
+ <a-typography-paragraph
+ :type="instanceDetail?.registerState === 'Registered' ?
'success' : 'danger'"
+ >
+ {{ instanceDetail?.registerState }}
+ </a-typography-paragraph>
+ </a-descriptions-item>
- <!-- Register Time -->
- <a-descriptions-item
- :label="$t('instanceDomain.registerTime')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-typography-paragraph>
- {{ formattedDate(instanceDetail?.registerTime) }}
- </a-typography-paragraph>
- </a-descriptions-item>
- </a-descriptions>
- </a-card>
- </a-col>
+ <!-- Register Time -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.registerTime')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-typography-paragraph>
+ {{ formattedDate(instanceDetail?.registerTime) }}
+ </a-typography-paragraph>
+ </a-descriptions-item>
+ </a-descriptions>
+ </a-card>
+ </a-col>
- <a-col :span="12">
- <a-card class="_detail" style="height: 100%">
- <a-descriptions class="description-column" :column="1">
- <a-descriptions-item
- :label="$t('instanceDomain.deployState')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-tag :color="deployColor(instanceDetail?.deployState)">
- {{ instanceDetail?.deployState }}
- </a-tag>
- </a-descriptions-item>
+ <a-col :span="12">
+ <a-card class="_detail" style="height: 100%">
+ <a-descriptions class="description-column" :column="1">
+ <a-descriptions-item
+ :label="$t('instanceDomain.deployState')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-tag :color="deployColor(instanceDetail?.deployState)">
+ {{ instanceDetail?.deployState }}
+ </a-tag>
+ </a-descriptions-item>
- <!-- Start time -->
- <a-descriptions-item
- :label="$t('instanceDomain.startTime_k8s')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-typography-paragraph>
- {{ formattedDate(instanceDetail?.startTime) }}
- </a-typography-paragraph>
- </a-descriptions-item>
+ <!-- Start time -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.startTime_k8s')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-typography-paragraph>
+ {{ formattedDate(instanceDetail?.startTime) }}
+ </a-typography-paragraph>
+ </a-descriptions-item>
- <!-- Ready time -->
- <!-- <a-descriptions-item
:label="$t('instanceDomain.readyTime_k8s')" :labelStyle="{ fontWeight: 'bold'
}">
+ <!-- Ready time -->
+ <!-- <a-descriptions-item
:label="$t('instanceDomain.readyTime_k8s')" :labelStyle="{ fontWeight: 'bold'
}">
<a-typography-paragraph>
{{ formattedDate(instanceDetail?.readyTime) }}
</a-typography-paragraph>
</a-descriptions-item> -->
- </a-descriptions>
- </a-card>
- </a-col>
- </a-row>
-
- <a-card style="margin-top: 10px" class="_detail">
- <a-descriptions class="description-column" :column="1">
- <!-- instanceIP -->
- <a-descriptions-item
- :label="$t('instanceDomain.instanceIP')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <p @click="copyIt(instanceDetail?.ip)"
class="description-item-content with-card">
- {{ instanceDetail?.ip }}
- <CopyOutlined />
- </p>
- </a-descriptions-item>
-
- <!-- deploy cluster -->
- <a-descriptions-item
- :label="$t('instanceDomain.deployCluster')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-typography-paragraph>
- {{ instanceDetail?.deployCluster }}
- </a-typography-paragraph>
- </a-descriptions-item>
+ </a-descriptions>
+ </a-card>
+ </a-col>
+ </a-row>
- <!-- Dubbo Port -->
- <a-descriptions-item
- :label="$t('instanceDomain.dubboPort')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <p
- v-if="instanceDetail?.rpcPort"
- @click="copyIt(instanceDetail?.rpcPort)"
- class="description-item-content with-card"
+ <a-card style="margin-top: 10px" class="_detail">
+ <a-descriptions class="description-column" :column="1">
+ <!-- instanceIP -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.instanceIP')"
+ :labelStyle="{ fontWeight: 'bold' }"
>
- {{ instanceDetail?.rpcPort }}
- <CopyOutlined />
- </p>
- </a-descriptions-item>
+ <p @click="copyIt(instanceDetail?.ip)"
class="description-item-content with-card">
+ {{ instanceDetail?.ip }}
+ <CopyOutlined />
+ </p>
+ </a-descriptions-item>
- <!-- Register cluster -->
- <a-descriptions-item
- :label="$t('instanceDomain.registerCluster')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-space>
- <a-typography-link v-for="cluster in
instanceDetail?.registerClusters">
- {{ cluster }}
- </a-typography-link>
- </a-space>
- </a-descriptions-item>
+ <!-- deploy cluster -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.deployCluster')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-typography-paragraph>
+ {{ instanceDetail?.deployCluster }}
+ </a-typography-paragraph>
+ </a-descriptions-item>
- <!-- whichApplication -->
- <a-descriptions-item
- :label="$t('instanceDomain.whichApplication')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-typography-link
@click="checkApplication(instanceDetail?.appName)">
- {{ instanceDetail?.appName }}
- </a-typography-link>
- </a-descriptions-item>
+ <!-- Dubbo Port -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.dubboPort')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <p
+ v-if="instanceDetail?.rpcPort"
+ @click="copyIt(instanceDetail?.rpcPort)"
+ class="description-item-content with-card"
+ >
+ {{ instanceDetail?.rpcPort }}
+ <CopyOutlined />
+ </p>
+ </a-descriptions-item>
- <!-- Node IP -->
- <a-descriptions-item
- :label="$t('instanceDomain.node')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <p
- v-if="instanceDetail?.node"
- @click="copyIt(instanceDetail?.node)"
- class="description-item-content with-card"
+ <!-- Register cluster -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.registerCluster')"
+ :labelStyle="{ fontWeight: 'bold' }"
>
- {{ instanceDetail?.node }}
- <CopyOutlined />
- </p>
- </a-descriptions-item>
+ <a-space>
+ <a-typography-link v-for="cluster in
instanceDetail?.registerClusters">
+ {{ cluster }}
+ </a-typography-link>
+ </a-space>
+ </a-descriptions-item>
- <!-- Owning workload(k8s) -->
- <a-descriptions-item
- :label="$t('instanceDomain.owningWorkload_k8s')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-typography-paragraph>
- {{ instanceDetail?.workloadName }}
- </a-typography-paragraph>
- </a-descriptions-item>
+ <!-- whichApplication -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.whichApplication')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-typography-link
@click="checkApplication(instanceDetail?.appName)">
+ {{ instanceDetail?.appName }}
+ </a-typography-link>
+ </a-descriptions-item>
- <!-- image -->
- <a-descriptions-item
- v-if="instanceDetail?.image"
- :label="$t('instanceDomain.instanceImage_k8s')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-card class="description-item-card">
+ <!-- Node IP -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.node')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
<p
- @click="copyIt(instanceDetail?.image)"
+ v-if="instanceDetail?.node"
+ @click="copyIt(instanceDetail?.node)"
class="description-item-content with-card"
>
- {{ instanceDetail?.image }}
+ {{ instanceDetail?.node }}
<CopyOutlined />
</p>
- </a-card>
- </a-descriptions-item>
+ </a-descriptions-item>
- <!-- instanceLabel -->
- <a-descriptions-item
- v-if="instanceDetail?.labels &&
Object.keys(instanceDetail?.labels).length > 0"
- :label="$t('instanceDomain.instanceLabel')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-card class="description-item-card">
- <a-tag v-for="(value, key) in instanceDetail?.labels">
- {{ key }} : {{ value }}
- </a-tag>
- </a-card>
- </a-descriptions-item>
+ <!-- Owning workload(k8s) -->
+ <a-descriptions-item
+ :label="$t('instanceDomain.owningWorkload_k8s')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-typography-paragraph>
+ {{ instanceDetail?.workloadName }}
+ </a-typography-paragraph>
+ </a-descriptions-item>
- <!-- health examination -->
- <a-descriptions-item
- v-if="instanceDetail?.probes"
- :label="$t('instanceDomain.healthExamination_k8s')"
- :labelStyle="{ fontWeight: 'bold' }"
- >
- <a-card class="description-item-card">
- <p class="white_space">
- 启动探针(StartupProbe):{{
- isProbeOpen(instanceDetail?.probes?.startupProbe.open)
- }}
- 类型: {{ instanceDetail?.probes?.startupProbe.type }} 端口:{{
- instanceDetail?.probes?.startupProbe.port
- }}
- </p>
- <p class="white_space">
- 就绪探针(ReadinessProbe):{{
- isProbeOpen(instanceDetail?.probes?.readinessProbe.open)
- }}
- 类型: {{ instanceDetail?.probes?.readinessProbe.type }} 端口:{{
- instanceDetail?.probes?.readinessProbe.port
- }}
- </p>
- <p class="white_space">
- 存活探针(LivenessProbe):{{
- isProbeOpen(instanceDetail?.probes?.livenessProbe.open)
- }}
- 类型: {{ instanceDetail?.probes?.livenessProbe.type }} 端口:{{
- instanceDetail?.probes?.livenessProbe.port
- }}
- </p>
- </a-card>
- </a-descriptions-item>
- </a-descriptions>
- </a-card>
- </a-card-grid>
+ <!-- image -->
+ <a-descriptions-item
+ v-if="instanceDetail?.image"
+ :label="$t('instanceDomain.instanceImage_k8s')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-card class="description-item-card">
+ <p
+ @click="copyIt(instanceDetail?.image)"
+ class="description-item-content with-card"
+ >
+ {{ instanceDetail?.image }}
+ <CopyOutlined />
+ </p>
+ </a-card>
+ </a-descriptions-item>
+
+ <!-- instanceLabel -->
+ <a-descriptions-item
+ v-if="instanceDetail?.labels &&
Object.keys(instanceDetail?.labels).length > 0"
+ :label="$t('instanceDomain.instanceLabel')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-card class="description-item-card">
+ <a-tag v-for="(value, key) in instanceDetail?.labels">
+ {{ key }} : {{ value }}
+ </a-tag>
+ </a-card>
+ </a-descriptions-item>
+
+ <!-- health examination -->
+ <a-descriptions-item
+ v-if="instanceDetail?.probes"
+ :label="$t('instanceDomain.healthExamination_k8s')"
+ :labelStyle="{ fontWeight: 'bold' }"
+ >
+ <a-card class="description-item-card">
+ <p class="white_space">
+ 启动探针(StartupProbe):{{
+ isProbeOpen(instanceDetail?.probes?.startupProbe.open)
+ }}
+ 类型: {{ instanceDetail?.probes?.startupProbe.type }} 端口:{{
+ instanceDetail?.probes?.startupProbe.port
+ }}
+ </p>
+ <p class="white_space">
+ 就绪探针(ReadinessProbe):{{
+ isProbeOpen(instanceDetail?.probes?.readinessProbe.open)
+ }}
+ 类型: {{ instanceDetail?.probes?.readinessProbe.type }} 端口:{{
+ instanceDetail?.probes?.readinessProbe.port
+ }}
+ </p>
+ <p class="white_space">
+ 存活探针(LivenessProbe):{{
+ isProbeOpen(instanceDetail?.probes?.livenessProbe.open)
+ }}
+ 类型: {{ instanceDetail?.probes?.livenessProbe.type }} 端口:{{
+ instanceDetail?.probes?.livenessProbe.port
+ }}
+ </p>
+ </a-card>
+ </a-descriptions-item>
+ </a-descriptions>
+ </a-card>
+ </a-card-grid>
+ </template>
</a-flex>
</div>
</template>
<script lang="ts" setup>
-import { type ComponentInternalInstance, getCurrentInstance, onMounted,
reactive } from 'vue'
+import { type ComponentInternalInstance, getCurrentInstance, onMounted,
reactive, ref } from 'vue'
import { CopyOutlined } from '@ant-design/icons-vue'
import useClipboard from 'vue-clipboard3'
import { message } from 'ant-design-vue'
@@ -260,6 +263,7 @@ let PRIMARY_COLOR_20 = PRIMARY_COLOR_T('20')
// instance detail information
const instanceDetail = <any>reactive({})
+const instanceMissing = ref(false)
onMounted(async () => {
const { name, pathId } = route.params
@@ -267,8 +271,12 @@ onMounted(async () => {
instanceName: name,
instanceIP: pathId
}
- apiData.detail = await getInstanceDetail(params)
- Object.assign(instanceDetail, apiData.detail.data)
+ try {
+ apiData.detail = await getInstanceDetail(params, { silentError: true })
+ Object.assign(instanceDetail, apiData.detail.data)
+ } catch {
+ instanceMissing.value = true
+ }
})
// Click on the application name to view the application
diff --git a/ui-vue3/src/views/resources/instances/tabs/event.vue
b/ui-vue3/src/views/resources/instances/tabs/event.vue
index a3cf4d1f..3151194c 100644
--- a/ui-vue3/src/views/resources/instances/tabs/event.vue
+++ b/ui-vue3/src/views/resources/instances/tabs/event.vue
@@ -16,11 +16,68 @@
-->
<template>
- <div>event todo</div>
+ <EventTimeline
+ :events="eventList"
+ :loading="loading"
+ :loadingMore="loadingMore"
+ :hasMore="hasMore"
+ @loadMore="loadEvents()"
+ />
</template>
<script lang="ts" setup>
-import { ref } from 'vue'
-</script>
+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<EventItem[]>([])
+const loading = ref(false)
+const loadingMore = ref(false)
+const hasMore = ref(false)
+const pageOffset = ref(0)
-<style lang="less" scoped></style>
+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)
+})
+</script>
diff --git a/ui-vue3/src/views/resources/services/tabs/event.vue
b/ui-vue3/src/views/resources/services/tabs/event.vue
index d2d41afa..a746529d 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.
-->
+
<template>
- <div class="__container_services_tabs_event">
- <a-card class="timeline-container">
- <a-timeline class="timeline">
- <a-timeline-item>
- <template #dot><MinusCircleOutlined style="font-size: 18px"
/></template>
- </a-timeline-item>
- <a-timeline-item v-for="(item, index) in eventData" :key="index">
- <a-tag class="time" :color="PRIMARY_COLOR">{{ item.time }}</a-tag>
- <span class="description">{{ item.description }}</span>
- </a-timeline-item>
- <a-timeline-item>
- <template #dot></template>
- <span>过期事件不会存储</span>
- </a-timeline-item>
- </a-timeline>
- </a-card>
- </div>
+ <EventTimeline
+ :events="eventList"
+ :loading="loading"
+ :loadingMore="loadingMore"
+ :hasMore="hasMore"
+ @loadMore="loadEvents()"
+ />
</template>
-<script setup lang="ts">
-import { PRIMARY_COLOR } from '@/base/constants'
-import { MinusCircleOutlined } from '@ant-design/icons-vue'
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import { useRoute } from 'vue-router'
+import { useMeshStore } from '@/stores/mesh'
+import { listServiceEvent } from '@/api/service/service'
+import EventTimeline from '@/components/EventTimeline.vue'
+import type { EventItem } from '@/types/api'
-let __null = PRIMARY_COLOR
-const eventData = [
- {
- time: '2022-01-01',
- description: 'description'
- },
- {
- time: '2022-01-02',
- description: 'description'
- },
- {
- time: '2022-01-03',
- description: 'description'
- },
- {
- time: '2022-01-04',
- description: 'description'
- },
- {
- time: '2022-01-05',
- description: 'description'
- }
-]
-</script>
+const route = useRoute()
+const meshStore = useMeshStore()
+const defaultPageSize = 20
+
+const eventList = ref<EventItem[]>([])
+const loading = ref(false)
+const loadingMore = ref(false)
+const hasMore = ref(false)
+const pageOffset = ref(0)
-<style lang="less" scoped>
-.__container_services_tabs_event {
- display: flex;
- .timeline-container {
- width: 100%;
- .timeline {
- margin-left: 30px;
- .description {
- font-size: 18px;
- }
- }
+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 serviceName = (route.params.pathId as string) || ''
+ const mesh = meshStore.mesh || 'default'
+ const currentOffset = reset ? 0 : pageOffset.value
+ const res = await listServiceEvent({
+ serviceName,
+ 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
}
}
-</style>
+
+onMounted(async () => {
+ pageOffset.value = 0
+ eventList.value = []
+ hasMore.value = true
+ await loadEvents(true)
+})
+</script>