From eec72c0cec07f9f4266b545147a5a94d8c7896a3 Mon Sep 17 00:00:00 2001 From: ryanchia Date: Wed, 11 Sep 2024 14:09:53 +0800 Subject: [PATCH 01/10] feat: add RemainAfterDeleted to ResourceSyncRule --- pkg/kubernetes/apis/search/types.go | 3 +++ pkg/kubernetes/apis/search/v1beta1/types.go | 4 ++++ .../apis/search/v1beta1/zz_generated.conversion.go | 2 ++ pkg/kubernetes/generated/openapi/zz_generated.openapi.go | 7 +++++++ 4 files changed, 16 insertions(+) diff --git a/pkg/kubernetes/apis/search/types.go b/pkg/kubernetes/apis/search/types.go index 200869f9..3a162ac9 100644 --- a/pkg/kubernetes/apis/search/types.go +++ b/pkg/kubernetes/apis/search/types.go @@ -111,6 +111,9 @@ type ResourceSyncRule struct { // TrimRefName is the name of the TrimRule. TrimRefName string `json:"trimRefName,omitempty"` + + // RemainAfterDeleted indicates whether the resource should remain in ES after being deleted in k8s. + RemainAfterDeleted bool } // +genclient diff --git a/pkg/kubernetes/apis/search/v1beta1/types.go b/pkg/kubernetes/apis/search/v1beta1/types.go index 8e19bd7a..84f8a530 100644 --- a/pkg/kubernetes/apis/search/v1beta1/types.go +++ b/pkg/kubernetes/apis/search/v1beta1/types.go @@ -131,6 +131,10 @@ type ResourceSyncRule struct { // TrimRefName is the name of the TrimRule. // +optional TrimRefName string `json:"trimRefName,omitempty"` + + // RemainAfterDeleted indicates whether the resource should remain in ES after being deleted in k8s. + // +optional + RemainAfterDeleted bool `json:"remainAfterDeleted,omitempty"` } // +genclient diff --git a/pkg/kubernetes/apis/search/v1beta1/zz_generated.conversion.go b/pkg/kubernetes/apis/search/v1beta1/zz_generated.conversion.go index 7c78cbaa..2e36aaa5 100644 --- a/pkg/kubernetes/apis/search/v1beta1/zz_generated.conversion.go +++ b/pkg/kubernetes/apis/search/v1beta1/zz_generated.conversion.go @@ -317,6 +317,7 @@ func autoConvert_v1beta1_ResourceSyncRule_To_search_ResourceSyncRule(in *Resourc out.TransformRefName = in.TransformRefName out.Trim = (*search.TrimRuleSpec)(unsafe.Pointer(in.Trim)) out.TrimRefName = in.TrimRefName + out.RemainAfterDeleted = in.RemainAfterDeleted return nil } @@ -336,6 +337,7 @@ func autoConvert_search_ResourceSyncRule_To_v1beta1_ResourceSyncRule(in *search. out.TransformRefName = in.TransformRefName out.Trim = (*TrimRuleSpec)(unsafe.Pointer(in.Trim)) out.TrimRefName = in.TrimRefName + out.RemainAfterDeleted = in.RemainAfterDeleted return nil } diff --git a/pkg/kubernetes/generated/openapi/zz_generated.openapi.go b/pkg/kubernetes/generated/openapi/zz_generated.openapi.go index f039da07..116b9869 100644 --- a/pkg/kubernetes/generated/openapi/zz_generated.openapi.go +++ b/pkg/kubernetes/generated/openapi/zz_generated.openapi.go @@ -729,6 +729,13 @@ func schema_kubernetes_apis_search_v1beta1_ResourceSyncRule(ref common.Reference Format: "", }, }, + "remainAfterDeleted": { + SchemaProps: spec.SchemaProps{ + Description: "RemainAfterDeleted indicates whether the resource should remain in ES after being deleted in k8s.", + Type: []string{"boolean"}, + Format: "", + }, + }, }, Required: []string{"apiVersion", "resource"}, }, From adc3e6a38dd20f947d2afbf1eac1ccbb9b82a7b9 Mon Sep 17 00:00:00 2001 From: ryanchia Date: Wed, 11 Sep 2024 14:11:00 +0800 Subject: [PATCH 02/10] feat: add syncAt and deleted fields to storage --- pkg/core/entity/resource_group.go | 12 +++---- pkg/core/handler/search/search.go | 2 ++ pkg/core/manager/search/types.go | 2 ++ pkg/infra/persistence/elasticsearch/client.go | 21 ++++++++++++ .../search/storage/elasticsearch/resource.go | 32 +++++++++++++++++++ pkg/infra/search/storage/types.go | 11 +++++++ pkg/syncer/syncer.go | 18 +++++++++-- 7 files changed, 90 insertions(+), 8 deletions(-) diff --git a/pkg/core/entity/resource_group.go b/pkg/core/entity/resource_group.go index ec15f2dd..b14a4d0c 100644 --- a/pkg/core/entity/resource_group.go +++ b/pkg/core/entity/resource_group.go @@ -42,12 +42,12 @@ const ( // ResourceGroup represents information required to locate a resource or multi resources. type ResourceGroup struct { - Cluster string `json:"cluster,omitempty" yaml:"cluster,omitempty"` - APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` - Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` - Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` - Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Cluster string `json:"cluster,omitempty" yaml:"cluster,omitempty"` + APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` + Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` } diff --git a/pkg/core/handler/search/search.go b/pkg/core/handler/search/search.go index 6a92539d..ee53b1ea 100644 --- a/pkg/core/handler/search/search.go +++ b/pkg/core/handler/search/search.go @@ -93,6 +93,8 @@ func SearchForResource(searchMgr *search.SearchManager, searchStorage storage.Se rt.Items = append(rt.Items, search.UniResource{ Cluster: res.Cluster, Object: obj, + SyncAt: res.SyncAt, + Deleted: res.Deleted, }) } rt.Total = res.Total diff --git a/pkg/core/manager/search/types.go b/pkg/core/manager/search/types.go index eb63b417..d51caeeb 100644 --- a/pkg/core/manager/search/types.go +++ b/pkg/core/manager/search/types.go @@ -28,6 +28,8 @@ func NewSearchManager() *SearchManager { type UniResource struct { Cluster string `json:"cluster"` Object any `json:"object"` + SyncAt string `json:"syncAt"` + Deleted bool `json:"deleted"` } type UniResourceList struct { diff --git a/pkg/infra/persistence/elasticsearch/client.go b/pkg/infra/persistence/elasticsearch/client.go index f9f5a71b..8bc7b878 100644 --- a/pkg/infra/persistence/elasticsearch/client.go +++ b/pkg/infra/persistence/elasticsearch/client.go @@ -117,6 +117,27 @@ func (cl *Client) GetDocument( return getResp.Source, nil } +// UpdateDocument updates a document with the specified ID +func (cl *Client) UpdateDocument( + ctx context.Context, + indexName string, + documentID string, + body io.Reader, +) error { + resp, err := cl.client.Update(indexName, documentID, body, cl.client.Update.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.IsError() { + return &ESError{ + StatusCode: resp.StatusCode, + Message: resp.String(), + } + } + return nil +} + // DeleteDocument deletes a document with the specified ID func (cl *Client) DeleteDocument(ctx context.Context, indexName string, documentID string) error { if _, err := cl.GetDocument(ctx, indexName, documentID); err != nil { diff --git a/pkg/infra/search/storage/elasticsearch/resource.go b/pkg/infra/search/storage/elasticsearch/resource.go index d0c4efe6..70511e43 100644 --- a/pkg/infra/search/storage/elasticsearch/resource.go +++ b/pkg/infra/search/storage/elasticsearch/resource.go @@ -19,6 +19,8 @@ import ( "context" "encoding/json" "fmt" + "time" + "github.com/KusionStack/karpor/pkg/infra/search/storage" "github.com/elliotxx/esquery" "k8s.io/apimachinery/pkg/api/meta" @@ -39,6 +41,8 @@ const ( resourceKeyOwnerReferences = "ownerReferences" resourceKeyResourceVersion = "resourceVersion" resourceKeyContent = "content" + resourceKeySyncAt = "syncAt" // resource save/update/delete time + resourceKeyDeleted = "deleted" // indicates whether the resource is deleted in cluster ) var ErrNotFound = fmt.Errorf("object not found") @@ -52,6 +56,32 @@ func (s *Storage) SaveResource(ctx context.Context, cluster string, obj runtime. return s.client.SaveDocument(ctx, s.resourceIndexName, id, bytes.NewReader(body)) } +// SoftDeleteResource only sets the deleted field to true, not really deletes the data in storage. +func (s *Storage) SoftDeleteResource(ctx context.Context, cluster string, obj runtime.Object) error { + unObj, ok := obj.(*unstructured.Unstructured) + if !ok { + // TODO: support other implement of runtime.Object + return fmt.Errorf("only support *unstructured.Unstructured type") + } + + if err := s.GetResource(ctx, cluster, unObj); err != nil { + return err + } + + body, err := json.Marshal(map[string]map[string]interface{}{ + "doc": { + resourceKeySyncAt: time.Now(), + resourceKeyDeleted: true, + }, + }) + if err != nil { + return err + } + + id := string(unObj.GetUID()) + return s.client.UpdateDocument(ctx, s.resourceIndexName, id, bytes.NewReader(body)) +} + // DeleteResource removes an object from the Elasticsearch storage for the specified cluster. func (s *Storage) DeleteResource(ctx context.Context, cluster string, obj runtime.Object) error { unObj, ok := obj.(*unstructured.Unstructured) @@ -142,6 +172,8 @@ func (s *Storage) generateResourceDocument(cluster string, obj runtime.Object) ( resourceKeyOwnerReferences: metaObj.GetOwnerReferences(), resourceKeyResourceVersion: metaObj.GetResourceVersion(), resourceKeyContent: buf.String(), + resourceKeySyncAt: time.Now(), + resourceKeyDeleted: false, }) if err != nil { return diff --git a/pkg/infra/search/storage/types.go b/pkg/infra/search/storage/types.go index 7cb6c73b..304d5bde 100644 --- a/pkg/infra/search/storage/types.go +++ b/pkg/infra/search/storage/types.go @@ -50,6 +50,7 @@ type ResourceStorage interface { DeleteResource(ctx context.Context, cluster string, obj runtime.Object) error DeleteAllResources(ctx context.Context, cluster string) error CountResources(ctx context.Context) (int, error) + SoftDeleteResource(ctx context.Context, cluster string, obj runtime.Object) error } // ResourceGroupRuleStorage interface defines the basic operations for resource @@ -162,6 +163,8 @@ func (r *SearchResult) ToYAML() (string, error) { type Resource struct { entity.ResourceGroup `json:",inline" yaml:",inline"` Object map[string]interface{} `json:"object"` + SyncAt string `json:"syncAt,omitempty"` + Deleted bool `json:"deleted,omitempty"` } // NewResource creates a new Resource instance based on the provided bytes @@ -204,6 +207,14 @@ func Map2Resource(in map[string]interface{}) (*Resource, error) { out.Namespace = in["namespace"].(string) out.Name = in["name"].(string) + // These two fields are newly added, so they don't exist in the old data. + if v, ok := in["syncAt"]; ok { + out.SyncAt = v.(string) + } + if v, ok := in["deleted"]; ok { + out.Deleted = v.(bool) + } + content := in["content"].(string) obj := &unstructured.Unstructured{} decoder := yamlutil.NewYAMLOrJSONDecoder(bytes.NewBufferString(content), len(content)) diff --git a/pkg/syncer/syncer.go b/pkg/syncer/syncer.go index 64683b58..32acc56f 100644 --- a/pkg/syncer/syncer.go +++ b/pkg/syncer/syncer.go @@ -30,6 +30,7 @@ import ( "github.com/go-logr/logr" "github.com/pkg/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/dynamic" @@ -185,6 +186,19 @@ func (s *ResourceSyncer) processNextWorkItem(ctx context.Context) bool { return true } +func (s *ResourceSyncer) saveResource(ctx context.Context, obj runtime.Object) error { + return s.storage.SaveResource(ctx, s.source.Cluster(), obj) +} + +func (s *ResourceSyncer) deleteResource(ctx context.Context, obj runtime.Object) error { + remainAfterDeleted := s.source.SyncRule().RemainAfterDeleted + if remainAfterDeleted { + return s.storage.SoftDeleteResource(ctx, s.source.Cluster(), obj) + } + + return s.storage.DeleteResource(ctx, s.source.Cluster(), obj) +} + // sync synchronizes the specified resource based on the key provided. func (s *ResourceSyncer) sync(ctx context.Context, key string) error { val, exists, err := s.source.GetByKey(key) @@ -203,11 +217,11 @@ func (s *ResourceSyncer) sync(ctx context.Context, key string) error { if exists { op = "save" obj := val.(*unstructured.Unstructured) - err = s.storage.SaveResource(ctx, s.source.Cluster(), obj) + err = s.saveResource(ctx, obj) } else { op = "delete" obj := genUnObj(s.SyncRule(), key) - err = s.storage.DeleteResource(ctx, s.source.Cluster(), obj) + err = s.deleteResource(ctx, obj) if errors.Is(err, elasticsearch.ErrNotFound) { s.logger.Error(err, "failed to sync", "key", key, "op", op) err = nil From 501ceccfce445eb5543e2c5665e5ab581c3e3457 Mon Sep 17 00:00:00 2001 From: ryanchia Date: Sat, 14 Sep 2024 19:12:20 +0800 Subject: [PATCH 03/10] feat: support getting resource yaml from ES --- pkg/core/entity/resource_group.go | 28 ++++++++++++++++++++++++++++ pkg/core/manager/insight/resource.go | 22 ++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/pkg/core/entity/resource_group.go b/pkg/core/entity/resource_group.go index b14a4d0c..c0b75a5d 100644 --- a/pkg/core/entity/resource_group.go +++ b/pkg/core/entity/resource_group.go @@ -89,6 +89,34 @@ func (rg *ResourceGroup) Hash() ResourceGroupHash { return ResourceGroupHash(hash.String()) } +// ToTerms converts the ResourceGroup to ES query terms. +func (rg *ResourceGroup) ToTerms() map[string]any { + terms := map[string]any{} + + setIfNotEmpty := func(key string, val any) { + switch val := val.(type) { + case string: + if len(val) != 0 { + terms[key] = val + } + case map[string]string: + if len(val) != 0 { + terms[key] = val + } + } + } + + setIfNotEmpty("cluster", rg.Cluster) + setIfNotEmpty("apiVersion", rg.APIVersion) + setIfNotEmpty("kind", rg.Kind) + setIfNotEmpty("namespace", rg.Namespace) + setIfNotEmpty("name", rg.Name) + setIfNotEmpty("labels", rg.Labels) + setIfNotEmpty("annotations", rg.Annotations) + + return terms +} + // ToSQL generates a SQL query string based on the ResourceGroup. func (rg *ResourceGroup) ToSQL() string { conditions := []string{} diff --git a/pkg/core/manager/insight/resource.go b/pkg/core/manager/insight/resource.go index 76cd6def..4c3b8bc1 100644 --- a/pkg/core/manager/insight/resource.go +++ b/pkg/core/manager/insight/resource.go @@ -22,13 +22,14 @@ import ( "github.com/KusionStack/karpor/pkg/core/handler" "github.com/KusionStack/karpor/pkg/infra/multicluster" topologyutil "github.com/KusionStack/karpor/pkg/util/topology" + k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" k8syaml "sigs.k8s.io/yaml" ) -// GetResource returns the unstructured cluster object for a given cluster. -func (i *InsightManager) GetResource( +// getResource gets the resource from the cluster or storage. +func (i *InsightManager) getResource( ctx context.Context, client *multicluster.MultiClusterClient, resourceGroup *entity.ResourceGroup, ) (*unstructured.Unstructured, error) { resourceGVR, err := topologyutil.GetGVRFromGVK(resourceGroup.APIVersion, resourceGroup.Kind) @@ -36,6 +37,23 @@ func (i *InsightManager) GetResource( return nil, err } resource, err := client.DynamicClient.Resource(resourceGVR).Namespace(resourceGroup.Namespace).Get(ctx, resourceGroup.Name, metav1.GetOptions{}) + + if err != nil && k8serrors.IsNotFound(err) { + if r, err := i.search.SearchByTerms(ctx, resourceGroup.ToTerms(), nil); err == nil && len(r.Resources) > 0 { + resource = &unstructured.Unstructured{} + resource.SetUnstructuredContent(r.Resources[0].Object) + return resource, nil + } + } + + return resource, err +} + +// GetResource returns the unstructured cluster object for a given cluster. +func (i *InsightManager) GetResource( + ctx context.Context, client *multicluster.MultiClusterClient, resourceGroup *entity.ResourceGroup, +) (*unstructured.Unstructured, error) { + resource, err := i.getResource(ctx, client, resourceGroup) if err != nil { return nil, err } From 70aeea26b1f77864c59ed4ab3a42163d7e30ab3d Mon Sep 17 00:00:00 2001 From: tianahi Date: Fri, 20 Sep 2024 17:52:28 +0800 Subject: [PATCH 04/10] feat: soft deleted --- .../insightDetail/components/sourceTable/index.tsx | 11 ++++++++++- ui/src/pages/insightDetail/namespace/index.tsx | 12 ++++++++++-- ui/src/pages/insightDetail/resource/index.tsx | 12 ++++++++++-- ui/src/pages/result/index.tsx | 9 ++++++++- ui/src/pages/result/styles.module.less | 7 +++++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/ui/src/pages/insightDetail/components/sourceTable/index.tsx b/ui/src/pages/insightDetail/components/sourceTable/index.tsx index 3be020a9..cf50738d 100644 --- a/ui/src/pages/insightDetail/components/sourceTable/index.tsx +++ b/ui/src/pages/insightDetail/components/sourceTable/index.tsx @@ -1,5 +1,5 @@ import { SearchOutlined } from '@ant-design/icons' -import { Button, Input, Space, Table } from 'antd' +import { Button, Input, Space, Table, Tag } from 'antd' import queryString from 'query-string' import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -37,6 +37,7 @@ const SourceTable = ({ queryStr, tableName }: IProps) => { function goResourcePage(record) { const nav = record?.object?.kind === 'Namespace' ? 'namespace' : 'resource' const params = { + deleted: record?.deleted, from: urlSearchParams?.from, type: nav, query: urlSearchParams?.query, @@ -86,6 +87,14 @@ const SourceTable = ({ queryStr, tableName }: IProps) => { title: 'Kind', render: (_, record) => record?.object?.kind, }, + { + dataIndex: 'deleted', + key: 'deleted', + title: 'status', + render: text => { + return text ? {t('Deleted')} : null + }, + }, ] const { diff --git a/ui/src/pages/insightDetail/namespace/index.tsx b/ui/src/pages/insightDetail/namespace/index.tsx index f10ddd89..165429ad 100644 --- a/ui/src/pages/insightDetail/namespace/index.tsx +++ b/ui/src/pages/insightDetail/namespace/index.tsx @@ -29,7 +29,9 @@ const ClusterDetail = () => { urlParams const [drawerVisible, setDrawerVisible] = useState(false) const [k8sDrawerVisible, setK8sDrawerVisible] = useState(false) - const [currentTab, setCurrentTab] = useState('Topology') + const [currentTab, setCurrentTab] = useState( + urlParams?.deleted ? 'YAML' : 'Topology', + ) const [modalVisible, setModalVisible] = useState(false) const [tableQueryStr, setTableQueryStr] = useState() const [yamlData, setYamlData] = useState('') @@ -189,6 +191,7 @@ const ClusterDetail = () => { }, [topologyDataResponse]) function getTopologyData() { + if (urlParams?.deleted) return topologyDataRefetch({ option: { params: { @@ -399,7 +402,12 @@ const ClusterDetail = () => {
{ + if (item?.value === 'Topology' && urlParams?.deleted) { + item.disabled = true + } + return item + })} current={currentTab} onChange={handleTabChange} /> diff --git a/ui/src/pages/insightDetail/resource/index.tsx b/ui/src/pages/insightDetail/resource/index.tsx index 6ebc4558..f9760d80 100644 --- a/ui/src/pages/insightDetail/resource/index.tsx +++ b/ui/src/pages/insightDetail/resource/index.tsx @@ -28,7 +28,9 @@ const ClusterDetail = () => { urlParams const [drawerVisible, setDrawerVisible] = useState(false) const [k8sDrawerVisible, setK8sDrawerVisible] = useState(false) - const [currentTab, setCurrentTab] = useState('Topology') + const [currentTab, setCurrentTab] = useState( + urlParams?.deleted ? 'YAML' : 'Topology', + ) const [modalVisible, setModalVisible] = useState(false) const [yamlData, setYamlData] = useState('') const [auditList, setAuditList] = useState([]) @@ -170,6 +172,7 @@ const ClusterDetail = () => { }, [topologyDataResponse]) function getTopologyData() { + if (urlParams?.deleted) return topologyDataRefetch({ option: { params: { @@ -390,7 +393,12 @@ const ClusterDetail = () => {
{ + if (item?.value === 'Topology' && urlParams?.deleted) { + item.disabled = true + } + return item + })} current={currentTab} onChange={handleTabChange} /> diff --git a/ui/src/pages/result/index.tsx b/ui/src/pages/result/index.tsx index 358c8a4d..bb8e5484 100644 --- a/ui/src/pages/result/index.tsx +++ b/ui/src/pages/result/index.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { Pagination, Empty, Divider, Tooltip } from 'antd' +import { Pagination, Empty, Divider, Tooltip, Tag } from 'antd' import { useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { ClockCircleOutlined } from '@ant-design/icons' @@ -98,6 +98,7 @@ const Result = () => { const nav = key === 'name' ? 'resource' : key const objParams = { from: 'result', + deleted: item?.deleted, cluster: item?.cluster, apiVersion: item?.object?.apiVersion, type: key, @@ -114,6 +115,7 @@ const Result = () => { const nav = kind === 'Namespace' ? 'namespace' : 'resource' const objParams = { from: 'result', + deleted: item?.deleted, cluster: item?.cluster, apiVersion: item?.object?.apiVersion, type: nav, @@ -170,6 +172,11 @@ const Result = () => { {pageData?.map((item: any, index: number) => { return (
+ {item?.deleted && ( +
+ {t('Delete')} +
+ )}
Date: Fri, 20 Sep 2024 16:17:06 +0800 Subject: [PATCH 05/10] feat: search not return deleted resources by default --- .../search/storage/elasticsearch/search.go | 2 +- pkg/util/sql2es/convert.go | 42 +++++++++++++++++++ pkg/util/sql2es/convert_test.go | 38 +++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/pkg/infra/search/storage/elasticsearch/search.go b/pkg/infra/search/storage/elasticsearch/search.go index 2118a68d..d5dd919d 100644 --- a/pkg/infra/search/storage/elasticsearch/search.go +++ b/pkg/infra/search/storage/elasticsearch/search.go @@ -87,7 +87,7 @@ func (s *Storage) searchByDSL(ctx context.Context, dslStr string, pagination *st // searchBySQL performs a search operation using an SQL string and pagination settings. func (s *Storage) searchBySQL(ctx context.Context, sqlStr string, pagination *storage.Pagination) (*storage.SearchResult, error) { - dsl, _, err := sql2es.Convert(sqlStr) + dsl, _, err := sql2es.ConvertWithDefaultFilter(sqlStr, &sql2es.DeletedFilter) if err != nil { return nil, err } diff --git a/pkg/util/sql2es/convert.go b/pkg/util/sql2es/convert.go index b4738429..8a56a811 100644 --- a/pkg/util/sql2es/convert.go +++ b/pkg/util/sql2es/convert.go @@ -20,9 +20,50 @@ import ( "strings" "github.com/xwb1989/sqlparser" + "k8s.io/apimachinery/pkg/util/sets" ) +var DeletedFilter = sqlparser.ComparisonExpr{ + Operator: sqlparser.EqualStr, + Left: &sqlparser.ColName{Name: sqlparser.NewColIdent("deleted")}, + Right: sqlparser.NewStrVal([]byte("false")), +} + +func applyDefaultFilter(sel *sqlparser.Select, filter sqlparser.Expr) *sqlparser.Select { + if filter == nil { + return sel + } + + getColNames := func(node sqlparser.SQLNode) sets.Set[string] { + names := sets.Set[string]{} + node.WalkSubtree(func(node sqlparser.SQLNode) (kontinue bool, err error) { + switch node.(type) { + case sqlparser.ColIdent, *sqlparser.ColIdent: + names.Insert(fmt.Sprintf("%v", node)) + } + return true, nil + }) + return names + } + + selColNames := getColNames(sel.Where) + filterColNames := getColNames(filter) + + if selColNames.Intersection(filterColNames).Len() > 0 { + return sel + } + + sel.AddWhere(filter) + return sel +} + func Convert(sql string) (dsl string, table string, err error) { + return ConvertWithDefaultFilter(sql, nil) +} + +// ConvertWithDefaultFilter appends the filter to sql where clause if the +// filter column names have no intersection with where clause. +func ConvertWithDefaultFilter(sql string, filter sqlparser.Expr) (dsl string, table string, err error) { stmt, err := sqlparser.Parse(sql) if err != nil { return "", "", err @@ -39,6 +80,7 @@ func Convert(sql string) (dsl string, table string, err error) { return "", "", fmt.Errorf("only one table supported") } + sel = applyDefaultFilter(sel, filter) return handleSelect(sel) } diff --git a/pkg/util/sql2es/convert_test.go b/pkg/util/sql2es/convert_test.go index 4a2ce889..bbd04128 100644 --- a/pkg/util/sql2es/convert_test.go +++ b/pkg/util/sql2es/convert_test.go @@ -15,6 +15,7 @@ package sql2es import ( + "bytes" "testing" "github.com/stretchr/testify/require" @@ -303,3 +304,40 @@ func TestHandleGroupByAgg(t *testing.T) { }) } } + +func Test_applyDefaultFilter(t *testing.T) { + tests := []struct { + name string + sql string + filter sqlparser.Expr + want string + }{ + {name: "not contain deleted filter", sql: "SELECT * FROM mock_table WHERE id = '123'", filter: &DeletedFilter, want: "select * from mock_table where id = '123' and deleted = 'false'"}, + {name: "not contain deleted filter (group by)", sql: "SELECT * FROM mock_table WHERE name like '%abc%' GROUP BY range(age, 18, 60)", filter: &DeletedFilter, want: "select * from mock_table where name like '%abc%' and deleted = 'false' group by range(age, 18, 60)"}, + {name: "contain deleted filter", sql: "SELECT * FROM mock_table WHERE id = '123' and deleted = 'true'", filter: &DeletedFilter, want: "select * from mock_table where id = '123' and deleted = 'true'"}, + {name: "contain deleted filter (parenthesize)", sql: "SELECT * FROM mock_table WHERE id = '123' and deleted in ('true')", filter: &DeletedFilter, want: "select * from mock_table where id = '123' and deleted in ('true')"}, + {name: "backquote", sql: "SELECT * FROM mock_table WHERE id = '123' and `deleted` = 'true'", filter: &DeletedFilter, want: "select * from mock_table where id = '123' and deleted = 'true'"}, + {name: "nil filter", sql: "SELECT * FROM mock_table WHERE id = '123'", filter: nil, want: "select * from mock_table where id = '123'"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := sqlparser.Parse(tt.sql) + if err != nil { + t.Errorf("sqlparser.Parse unexpected error = %v", err) + return + } + + sel := stmt.(*sqlparser.Select) + got := applyDefaultFilter(sel, tt.filter) + + var buf bytes.Buffer + tbuf := &sqlparser.TrackedBuffer{Buffer: &buf} + got.Format(tbuf) + + if got := tbuf.String(); got != tt.want { + t.Errorf("applyDefaultFilter() = %v, want %v", got, tt.want) + } + }) + } +} From c33df6fb628dd2ccd5f019c1b611fc0cdd49c10d Mon Sep 17 00:00:00 2001 From: ryanchia Date: Sun, 22 Sep 2024 22:14:35 +0800 Subject: [PATCH 06/10] feat: replace ESImporter with ESPurger to clean deleted data --- .../search/storage/elasticsearch/resource.go | 8 ++ pkg/infra/search/storage/types.go | 1 + pkg/syncer/source.go | 8 +- pkg/syncer/syncer.go | 46 +++++++- pkg/syncer/utils/purger.go | 103 ++++++++++++++++++ 5 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 pkg/syncer/utils/purger.go diff --git a/pkg/infra/search/storage/elasticsearch/resource.go b/pkg/infra/search/storage/elasticsearch/resource.go index 70511e43..0aeac26c 100644 --- a/pkg/infra/search/storage/elasticsearch/resource.go +++ b/pkg/infra/search/storage/elasticsearch/resource.go @@ -56,6 +56,14 @@ func (s *Storage) SaveResource(ctx context.Context, cluster string, obj runtime. return s.client.SaveDocument(ctx, s.resourceIndexName, id, bytes.NewReader(body)) } +// Refresh will update ES index. If you want the previous document changes to be +// searchable immediately, you need to call refresh manually. +// +// Refer to https://www.elastic.co/guide/en/elasticsearch/guide/current/near-real-time.html to see detail. +func (s *Storage) Refresh(ctx context.Context) error { + return s.client.Refresh(ctx, s.resourceIndexName) +} + // SoftDeleteResource only sets the deleted field to true, not really deletes the data in storage. func (s *Storage) SoftDeleteResource(ctx context.Context, cluster string, obj runtime.Object) error { unObj, ok := obj.(*unstructured.Unstructured) diff --git a/pkg/infra/search/storage/types.go b/pkg/infra/search/storage/types.go index 304d5bde..bde9e777 100644 --- a/pkg/infra/search/storage/types.go +++ b/pkg/infra/search/storage/types.go @@ -51,6 +51,7 @@ type ResourceStorage interface { DeleteAllResources(ctx context.Context, cluster string) error CountResources(ctx context.Context) (int, error) SoftDeleteResource(ctx context.Context, cluster string, obj runtime.Object) error + Refresh(ctx context.Context) error } // ResourceGroupRuleStorage interface defines the basic operations for resource diff --git a/pkg/syncer/source.go b/pkg/syncer/source.go index 6a31cbc0..70a6cdb3 100644 --- a/pkg/syncer/source.go +++ b/pkg/syncer/source.go @@ -20,7 +20,6 @@ import ( "time" "github.com/KusionStack/karpor/pkg/infra/search/storage" - "github.com/KusionStack/karpor/pkg/infra/search/storage/elasticsearch" "github.com/KusionStack/karpor/pkg/kubernetes/apis/search/v1beta1" "github.com/KusionStack/karpor/pkg/syncer/internal" "github.com/KusionStack/karpor/pkg/syncer/jsonextracter" @@ -164,7 +163,7 @@ func (s *informerSource) Stop(ctx context.Context) error { } // createInformer sets up and returns the informer and controller for the informerSource, using the provided context, event handler, workqueue, and predicates. -func (s *informerSource) createInformer(ctx context.Context, handler ctrlhandler.EventHandler, queue workqueue.RateLimitingInterface, predicates ...predicate.Predicate) (clientgocache.Store, clientgocache.Controller, error) { +func (s *informerSource) createInformer(_ context.Context, handler ctrlhandler.EventHandler, queue workqueue.RateLimitingInterface, predicates ...predicate.Predicate) (clientgocache.Store, clientgocache.Controller, error) { gvr, err := parseGVR(&s.ResourceSyncRule) if err != nil { return nil, nil, errors.Wrap(err, "error parsing GroupVersionResource") @@ -196,11 +195,6 @@ func (s *informerSource) createInformer(ctx context.Context, handler ctrlhandler h := &internal.EventHandler{EventHandler: handler, Queue: queue, Predicates: predicates} cache, informer := clientgocache.NewTransformingInformer(lw, &unstructured.Unstructured{}, resyncPeriod, h, trim) - // TODO: Use interface instead of struct - importer := utils.NewESImporter(s.storage.(*elasticsearch.Storage), s.cluster, gvr) - if err = importer.ImportTo(ctx, cache); err != nil { - return nil, nil, err - } return cache, informer, nil } diff --git a/pkg/syncer/syncer.go b/pkg/syncer/syncer.go index 32acc56f..43d638f7 100644 --- a/pkg/syncer/syncer.go +++ b/pkg/syncer/syncer.go @@ -26,6 +26,7 @@ import ( "github.com/KusionStack/karpor/pkg/infra/search/storage/elasticsearch" "github.com/KusionStack/karpor/pkg/kubernetes/apis/search/v1beta1" "github.com/KusionStack/karpor/pkg/syncer/transform" + "github.com/KusionStack/karpor/pkg/syncer/utils" sprig "github.com/Masterminds/sprig/v3" "github.com/go-logr/logr" "github.com/pkg/errors" @@ -40,7 +41,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -const defaultWorkers = 10 +const ( + defaultWorkers = 10 + + // purgeMarker indicates it's time to prune the storage. + // As k8s object name cannot use underscore, we can use this name in workqueue without collision. + purgeMarker = "__purge_marker__" +) // deleted is a type that represents a deleted Kubernetes object. type deleted struct { @@ -59,6 +66,7 @@ type ResourceSyncer struct { logger logr.Logger transformFunc clientgocache.TransformFunc + startTime time.Time } // NewResourceSyncer creates a new instance of the ResourceSyncer with the given parameters. @@ -119,6 +127,8 @@ func (s *ResourceSyncer) enqueue(obj client.Object) { // Run starts the ResourceSyncer and its workers to process Kubernetes object events. func (s *ResourceSyncer) Run(ctx context.Context) error { + s.startTime = time.Now() + s.ctx, s.cancel = context.WithCancel(ctx) defer utilruntime.HandleCrash() @@ -139,6 +149,10 @@ func (s *ResourceSyncer) Run(ctx context.Context) error { return fmt.Errorf("failed to wait for caches to sync") } + // We push the purgeMarker after cacheSync, meaning that when the purgeMarker + // is read from the queue, almost all resources have been synced. + s.queue.Add(purgeMarker) + workers := s.source.SyncRule().MaxConcurrent if workers <= 0 { workers = defaultWorkers @@ -162,6 +176,28 @@ func (s *ResourceSyncer) runWorker(ctx context.Context) { } } +func (s *ResourceSyncer) purgeStorage(ctx context.Context) { + err := s.storage.Refresh(ctx) + if err != nil { + s.logger.Error(err, "error in refreshing storage") + return + } + + r := s.SyncRule() + gvr, err := parseGVR(&r) + if err != nil { + s.logger.Error(err, "error in parsing GVR") + return + } + + // TODO: Use interface instead of struct + esPurger := utils.NewESPurger(s.storage.(*elasticsearch.Storage), s.source.Cluster(), gvr, s.source, s.OnDelete) + if err := esPurger.Purge(ctx, s.startTime); err != nil { + s.logger.Error(err, "error in purging ES") + return + } +} + // processNextWorkItem processes the next work item from the queue, returning true if work continues. func (s *ResourceSyncer) processNextWorkItem(ctx context.Context) bool { item, shutdown := s.queue.Get() @@ -169,6 +205,12 @@ func (s *ResourceSyncer) processNextWorkItem(ctx context.Context) bool { return false } key := item.(string) + + if key == purgeMarker { + s.purgeStorage(ctx) + return true + } + func() { defer s.queue.Done(item) @@ -178,7 +220,7 @@ func (s *ResourceSyncer) processNextWorkItem(ctx context.Context) bool { s.queue.AddRateLimited(item) return } else { - s.logger.Error(errors.New("retry reached max times"), "key", key) + s.logger.Error(err, "retry reached max times", "key", key) } } s.queue.Forget(item) diff --git a/pkg/syncer/utils/purger.go b/pkg/syncer/utils/purger.go new file mode 100644 index 00000000..fff3ce9b --- /dev/null +++ b/pkg/syncer/utils/purger.go @@ -0,0 +1,103 @@ +// Copyright The Karpor Authors. +// +// Licensed 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 utils + +import ( + "context" + "fmt" + "time" + + "github.com/KusionStack/karpor/pkg/infra/search/storage/elasticsearch" + "github.com/elliotxx/esquery" + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/cache" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Purger defines the interface for pruning data in storage. +type Purger interface { + Purge(ctx context.Context, syncBefore time.Time) error +} + +var _ Purger = (*ESPurger)(nil) + +// NewESPurger creates an ESPurger which implements the Purger interface. +func NewESPurger(esClient *elasticsearch.Storage, cluster string, gvr schema.GroupVersionResource, + store cache.Store, onPurge func(obj client.Object), +) *ESPurger { + return &ESPurger{ + cluster: cluster, + esClient: esClient, + gvr: gvr, + onPurge: onPurge, + store: store, + logger: ctrl.Log.WithName(fmt.Sprintf("%s-es-purger", gvr.Resource)), + } +} + +type ESPurger struct { + cluster string + esClient *elasticsearch.Storage + gvr schema.GroupVersionResource + onPurge func(obj client.Object) + store cache.Store + logger logr.Logger +} + +// Purge calls onPurge for objects that do not exist in the cache but have not been deleted in ES. +func (e *ESPurger) Purge(ctx context.Context, syncBefore time.Time) error { + resource := e.gvr.Resource + kind := resource[0 : len(resource)-1] + + query := make(map[string]interface{}) + query["query"] = esquery.Bool().Must( + esquery.Term("cluster", e.cluster), + esquery.Term("apiVersion", e.gvr.GroupVersion().String()), + esquery.Term("kind", kind), + esquery.Term("deleted", false), + esquery.Range("syncAt").Lte(syncBefore), + ).Map() + + sr, err := e.esClient.SearchByQuery(context.Background(), query, nil) + if err != nil { + return err + } + + for _, r := range sr.Resources { + obj := &unstructured.Unstructured{} + obj.SetUnstructuredContent(r.Object) + key, err := cache.MetaNamespaceKeyFunc(obj) + if err != nil { + e.logger.Error(err, "error in getting object key") + continue + } + + _, exist, err := e.store.GetByKey(key) + if err != nil { + e.logger.Error(err, "error in getting object by key") + continue + } + + if !exist { + e.logger.V(1).Info("found an object that should be purged", "key", key) + e.onPurge(obj) + } + } + + return nil +} From b969df8cc8852c1ad86a62b550eb9738768ff408 Mon Sep 17 00:00:00 2001 From: ryanchia Date: Mon, 23 Sep 2024 16:28:02 +0800 Subject: [PATCH 07/10] fix: fix unit test panic --- pkg/syncer/syncer_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/syncer/syncer_test.go b/pkg/syncer/syncer_test.go index d4433519..5d76e9ea 100644 --- a/pkg/syncer/syncer_test.go +++ b/pkg/syncer/syncer_test.go @@ -20,6 +20,7 @@ import ( "testing" "time" + "github.com/KusionStack/karpor/pkg/infra/search/storage" "github.com/KusionStack/karpor/pkg/infra/search/storage/elasticsearch" "github.com/KusionStack/karpor/pkg/kubernetes/apis/search/v1beta1" "github.com/bytedance/mockey" @@ -132,7 +133,11 @@ func TestResourceSyncer_Run(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := NewResourceSyncer("cluster1", nil, v1beta1.ResourceSyncRule{APIVersion: "v1", Resource: "services"}, &elasticsearch.Storage{}) m := mockey.Mock((*informerSource).HasSynced).Return(true).Build() + m2 := mockey.Mock((*elasticsearch.Storage).Refresh).Return(nil).Build() + m3 := mockey.Mock((*elasticsearch.Storage).SearchByQuery).Return(&storage.SearchResult{}, nil).Build() defer m.UnPatch() + defer m2.UnPatch() + defer m3.UnPatch() ctx, cancel := context.WithTimeout(context.TODO(), 1*time.Second) defer cancel() err := s.Run(ctx) From 3e537afcf40b779f1d6c8e2c7f231b7e3dce1694 Mon Sep 17 00:00:00 2001 From: tianahi Date: Wed, 25 Sep 2024 09:56:12 +0800 Subject: [PATCH 08/10] feat: soft deleted --- .../pages/insightDetail/namespace/index.tsx | 24 ++++++++++++----- ui/src/pages/insightDetail/resource/index.tsx | 26 ++++++++++++++----- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/ui/src/pages/insightDetail/namespace/index.tsx b/ui/src/pages/insightDetail/namespace/index.tsx index 165429ad..fe50eb9e 100644 --- a/ui/src/pages/insightDetail/namespace/index.tsx +++ b/ui/src/pages/insightDetail/namespace/index.tsx @@ -44,6 +44,22 @@ const ClusterDetail = () => { const [multiTopologyData, setMultiTopologyData] = useState() const [selectedCluster, setSelectedCluster] = useState() const [clusterOptions, setClusterOptions] = useState([]) + const [tabList, setTabList] = useState(insightTabsList) + + useEffect(() => { + if (urlParams?.deleted) { + const tmp = tabList?.map(item => { + if (item?.value === 'Topology' && urlParams?.deleted) { + item.disabled = true + } + return item + }) + setTabList(tmp) + } else { + setTabList(insightTabsList) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlParams]) useEffect(() => { if (selectedCluster) { @@ -398,16 +414,10 @@ const ClusterDetail = () => {
- {/* 拓扑图 */}
{ - if (item?.value === 'Topology' && urlParams?.deleted) { - item.disabled = true - } - return item - })} + list={tabList} current={currentTab} onChange={handleTabChange} /> diff --git a/ui/src/pages/insightDetail/resource/index.tsx b/ui/src/pages/insightDetail/resource/index.tsx index f9760d80..c48a846f 100644 --- a/ui/src/pages/insightDetail/resource/index.tsx +++ b/ui/src/pages/insightDetail/resource/index.tsx @@ -8,6 +8,7 @@ import Yaml from '@/components/yaml' import { capitalized, generateResourceTopologyData } from '@/utils/tools' import { insightTabsList } from '@/utils/constants' import { ICON_MAP } from '@/utils/images' +import { useAxios } from '@/utils/request' import ExceptionDrawer from '../components/exceptionDrawer' import TopologyMap from '../components/topologyMap' import ExceptionList from '../components/exceptionList' @@ -17,7 +18,6 @@ import K8sEventDrawer from '../components/k8sEventDrawer' import SummaryCard from '../components/summaryCard' import styles from './styles.module.less' -import { useAxios } from '@/utils/request' const ClusterDetail = () => { const { t, i18n } = useTranslation() @@ -42,6 +42,23 @@ const ClusterDetail = () => { const [selectedCluster, setSelectedCluster] = useState() const [clusterOptions, setClusterOptions] = useState([]) + const [tabList, setTabList] = useState(insightTabsList) + + useEffect(() => { + if (urlParams?.deleted) { + const tmp = tabList?.map(item => { + if (item?.value === 'Topology' && urlParams?.deleted) { + item.disabled = true + } + return item + }) + setTabList(tmp) + } else { + setTabList(insightTabsList) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlParams]) + function handleTabChange(value: string) { setCurrentTab(value) } @@ -393,12 +410,7 @@ const ClusterDetail = () => {
{ - if (item?.value === 'Topology' && urlParams?.deleted) { - item.disabled = true - } - return item - })} + list={tabList} current={currentTab} onChange={handleTabChange} /> From 652e6849a444a570c9719994e3d54c8662fa72ef Mon Sep 17 00:00:00 2001 From: tianahi Date: Wed, 25 Sep 2024 14:42:45 +0800 Subject: [PATCH 09/10] feat: soft deleted --- ui/src/pages/insightDetail/namespace/index.tsx | 18 ++++++++++++------ ui/src/pages/insightDetail/resource/index.tsx | 18 ++++++++++++------ ui/src/pages/result/styles.module.less | 4 ++-- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/ui/src/pages/insightDetail/namespace/index.tsx b/ui/src/pages/insightDetail/namespace/index.tsx index fe50eb9e..a202378c 100644 --- a/ui/src/pages/insightDetail/namespace/index.tsx +++ b/ui/src/pages/insightDetail/namespace/index.tsx @@ -30,7 +30,7 @@ const ClusterDetail = () => { const [drawerVisible, setDrawerVisible] = useState(false) const [k8sDrawerVisible, setK8sDrawerVisible] = useState(false) const [currentTab, setCurrentTab] = useState( - urlParams?.deleted ? 'YAML' : 'Topology', + urlParams?.deleted === 'true' ? 'YAML' : 'Topology', ) const [modalVisible, setModalVisible] = useState(false) const [tableQueryStr, setTableQueryStr] = useState() @@ -47,19 +47,25 @@ const ClusterDetail = () => { const [tabList, setTabList] = useState(insightTabsList) useEffect(() => { - if (urlParams?.deleted) { + if (urlParams?.deleted === 'true') { const tmp = tabList?.map(item => { - if (item?.value === 'Topology' && urlParams?.deleted) { + if (item?.value === 'Topology' && urlParams?.deleted === 'true') { item.disabled = true } return item }) setTabList(tmp) } else { - setTabList(insightTabsList) + const tmp = tabList?.map(item => { + if (item?.value === 'Topology') { + item.disabled = false + } + return item + }) + setTabList(tmp) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlParams]) + }, [urlParams?.deleted]) useEffect(() => { if (selectedCluster) { @@ -207,7 +213,7 @@ const ClusterDetail = () => { }, [topologyDataResponse]) function getTopologyData() { - if (urlParams?.deleted) return + if (urlParams?.deleted === 'true') return topologyDataRefetch({ option: { params: { diff --git a/ui/src/pages/insightDetail/resource/index.tsx b/ui/src/pages/insightDetail/resource/index.tsx index c48a846f..cc8675f1 100644 --- a/ui/src/pages/insightDetail/resource/index.tsx +++ b/ui/src/pages/insightDetail/resource/index.tsx @@ -29,7 +29,7 @@ const ClusterDetail = () => { const [drawerVisible, setDrawerVisible] = useState(false) const [k8sDrawerVisible, setK8sDrawerVisible] = useState(false) const [currentTab, setCurrentTab] = useState( - urlParams?.deleted ? 'YAML' : 'Topology', + urlParams?.deleted === 'true' ? 'YAML' : 'Topology', ) const [modalVisible, setModalVisible] = useState(false) const [yamlData, setYamlData] = useState('') @@ -45,19 +45,25 @@ const ClusterDetail = () => { const [tabList, setTabList] = useState(insightTabsList) useEffect(() => { - if (urlParams?.deleted) { + if (urlParams?.deleted === 'true') { const tmp = tabList?.map(item => { - if (item?.value === 'Topology' && urlParams?.deleted) { + if (item?.value === 'Topology' && urlParams?.deleted === 'true') { item.disabled = true } return item }) setTabList(tmp) } else { - setTabList(insightTabsList) + const tmp = tabList?.map(item => { + if (item?.value === 'Topology') { + item.disabled = false + } + return item + }) + setTabList(tmp) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlParams]) + }, [urlParams?.deleted]) function handleTabChange(value: string) { setCurrentTab(value) @@ -189,7 +195,7 @@ const ClusterDetail = () => { }, [topologyDataResponse]) function getTopologyData() { - if (urlParams?.deleted) return + if (urlParams?.deleted === 'true') return topologyDataRefetch({ option: { params: { diff --git a/ui/src/pages/result/styles.module.less b/ui/src/pages/result/styles.module.less index a28c51e6..c976d566 100644 --- a/ui/src/pages/result/styles.module.less +++ b/ui/src/pages/result/styles.module.less @@ -44,6 +44,7 @@ } .card { + position: relative; display: flex; width: 100%; padding: 16px 24px; @@ -52,12 +53,11 @@ border-radius: 8px; box-sizing: border-box; align-items: center; - position: relative; .delete_tag { position: absolute; - right: 10px; top: 10px; + right: 10px; } .left { From 5ddab8deffda212461c95ad89ef08cdcad66cabb Mon Sep 17 00:00:00 2001 From: tianahi Date: Wed, 25 Sep 2024 14:45:12 +0800 Subject: [PATCH 10/10] feat: soft deleted --- ui/src/pages/insightDetail/namespace/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/pages/insightDetail/namespace/index.tsx b/ui/src/pages/insightDetail/namespace/index.tsx index a202378c..00eeb747 100644 --- a/ui/src/pages/insightDetail/namespace/index.tsx +++ b/ui/src/pages/insightDetail/namespace/index.tsx @@ -214,6 +214,7 @@ const ClusterDetail = () => { function getTopologyData() { if (urlParams?.deleted === 'true') return + topologyDataRefetch({ option: { params: {