diff --git a/query/topology.go b/query/topology.go index 30d6a747..42b2b2ef 100644 --- a/query/topology.go +++ b/query/topology.go @@ -11,10 +11,18 @@ import ( "github.com/flanksource/duty/context" "github.com/flanksource/duty/models" "github.com/jackc/pgx/v5" + "github.com/samber/lo" ) const DefaultDepth = 5 +type TopologyQuerySortBy string + +const ( + TopologyQuerySortByName TopologyQuerySortBy = "name" + TopologyQuerySortByField TopologyQuerySortBy = "field:" +) + type TopologyOptions struct { ID string Owner string @@ -26,6 +34,9 @@ type TopologyOptions struct { Types []string Status []string + SortBy TopologyQuerySortBy + SortOrder string + // when set to true, only the children (except the direct children) are returned. // when set to false, the direct children & the parent itself is fetched. nonDirectChildrenOnly bool @@ -253,18 +264,62 @@ func Topology(ctx context.Context, params TopologyOptions) (*TopologyResponse, e // Remove fields from children that aren't required by the UI root := response.Components + if len(root) == 1 { for j := range root[0].Components { removeComponentFields(root[0].Components[j].Components) } + + if params.SortBy != "" { + sortComponents(root[0].Components, params.SortBy, params.SortOrder != "desc") + } } else { for i := range root { removeComponentFields(root[i].Components) } + + if params.SortBy != "" { + sortComponents(root, params.SortBy, params.SortOrder != "desc") + } } return &response, nil } +func sortComponents(c models.Components, sortBy TopologyQuerySortBy, asc bool) { + switch { + case sortBy == TopologyQuerySortByName: + sort.Slice(c, func(i, j int) bool { + if !asc { + i, j = j, i + } + return c[i].Name < c[j].Name + }) + + case strings.HasPrefix(string(sortBy), string(TopologyQuerySortByField)): + field := strings.TrimPrefix(string(sortBy), string(TopologyQuerySortByField)) + isTextProperty := lo.Reduce(c, func(val bool, comp *models.Component, _ int) bool { + return val && comp.Properties.Find(field).Text != "" + }, true) + + sort.Slice(c, func(i, j int) bool { + if !asc { + i, j = j, i + } + propI := c[i].Properties.Find(field) + propJ := c[j].Properties.Find(field) + if propI == nil || propJ == nil { + return false + } + + if isTextProperty { + return propI.Text < propJ.Text + } else { + return propI.Value < propJ.Value + } + }) + } +} + // applyDepthFilter limits the tree size to the given depth and also // dereferences pointer cycles by creating new copies of components // to prevent cyclic errors during json.Marshal diff --git a/tests/fixtures/dummy/components.go b/tests/fixtures/dummy/components.go index 9bcec032..7359884e 100644 --- a/tests/fixtures/dummy/components.go +++ b/tests/fixtures/dummy/components.go @@ -141,6 +141,7 @@ var LogisticsAPIPod = models.Component{ ParentId: &PodsComponent.ID, CreatedAt: DummyCreatedAt, Path: fmt.Sprintf("%s.%s", ClusterComponent.ID.String(), PodsComponent.ID.String()), + Properties: []*models.Property{{Name: "memory", Unit: "bytes", Value: 100}}, } var LogisticsUIPod = models.Component{ @@ -154,6 +155,7 @@ var LogisticsUIPod = models.Component{ ParentId: &PodsComponent.ID, CreatedAt: DummyCreatedAt, Path: fmt.Sprintf("%s.%s", ClusterComponent.ID.String(), PodsComponent.ID.String()), + Properties: []*models.Property{{Name: "memory", Unit: "bytes", Value: 200}}, } var LogisticsWorkerPod = models.Component{ @@ -167,6 +169,7 @@ var LogisticsWorkerPod = models.Component{ ParentId: &PodsComponent.ID, CreatedAt: DummyCreatedAt, Path: fmt.Sprintf("%s.%s", ClusterComponent.ID.String(), PodsComponent.ID.String()), + Properties: []*models.Property{{Name: "memory", Unit: "bytes", Value: 300}}, } var PaymentsAPI = models.Component{ diff --git a/tests/fixtures/expectations/topology_child_tree.json b/tests/fixtures/expectations/topology_child_tree.json index f5618b67..fa898d13 100644 --- a/tests/fixtures/expectations/topology_child_tree.json +++ b/tests/fixtures/expectations/topology_child_tree.json @@ -28,6 +28,13 @@ "tooltip": "Logistic API Pod", "icon": "icon-kubernetes-pod", "type": "KubernetesPod", + "properties": [ + { + "name": "memory", + "value": 100, + "unit": "bytes" + } + ], "path": "018681fe-8156-4b91-d178-caf8b3c2818c.018681ff-559f-7183-19d1-7d898b4e1413", "summary": { "healthy": 1 @@ -49,6 +56,13 @@ "tooltip": "Logistic UI Pod", "icon": "icon-kubernetes-pod", "type": "KubernetesPod", + "properties": [ + { + "name": "memory", + "value": 200, + "unit": "bytes" + } + ], "path": "018681fe-8156-4b91-d178-caf8b3c2818c.018681ff-559f-7183-19d1-7d898b4e1413", "summary": { "healthy": 1 diff --git a/tests/fixtures/expectations/topology_tree_with_desc_sort.json b/tests/fixtures/expectations/topology_tree_with_desc_sort.json new file mode 100644 index 00000000..a0c71264 --- /dev/null +++ b/tests/fixtures/expectations/topology_tree_with_desc_sort.json @@ -0,0 +1,117 @@ +{ + "components": [ + { + "id": "018681ff-559f-7183-19d1-7d898b4e1413", + "agent_id": "00000000-0000-0000-0000-000000000000", + "external_id": "dummy/pods", + "parent_id": "018681fe-8156-4b91-d178-caf8b3c2818c", + "name": "Pods", + "status": "healthy", + "tooltip": "Kubernetes Pods", + "icon": "icon-kubernetes-pod", + "type": "KubernetesPods", + "path": "018681fe-8156-4b91-d178-caf8b3c2818c", + "summary": { + "healthy": 3 + }, + "is_leaf": false, + "created_at": "2023-01-01T05:29:00+05:30", + "updated_at": "2023-01-01T05:29:00+05:30", + "components": [ + { + "id": "018681ff-e578-a926-e366-d2dc0646eafa", + "agent_id": "00000000-0000-0000-0000-000000000000", + "external_id": "dummy/logistics-worker-79cb67d8f5-lr66n", + "parent_id": "018681ff-559f-7183-19d1-7d898b4e1413", + "name": "logistics-worker-79cb67d8f5-lr66n", + "status": "healthy", + "tooltip": "Logistic Worker Pod", + "icon": "icon-kubernetes-pod", + "type": "KubernetesPod", + "properties": [ + { + "name": "memory", + "value": 300, + "unit": "bytes" + } + ], + "path": "018681fe-8156-4b91-d178-caf8b3c2818c.018681ff-559f-7183-19d1-7d898b4e1413", + "summary": { + "healthy": 1 + }, + "is_leaf": false, + "created_at": "2023-01-01T05:29:00+05:30", + "updated_at": "2023-01-01T05:29:00+05:30", + "parents": [ + "018681ff-227e-4d71-b38e-0693cc862213" + ] + }, + { + "id": "018681ff-b6c1-a14d-2fd4-8c7dac94cddd", + "agent_id": "00000000-0000-0000-0000-000000000000", + "external_id": "dummy/logistics-ui-676b85b87c-tjjcp", + "parent_id": "018681ff-559f-7183-19d1-7d898b4e1413", + "name": "logistics-ui-676b85b87c-tjjcp", + "status": "healthy", + "tooltip": "Logistic UI Pod", + "icon": "icon-kubernetes-pod", + "type": "KubernetesPod", + "properties": [ + { + "name": "memory", + "value": 200, + "unit": "bytes" + } + ], + "path": "018681fe-8156-4b91-d178-caf8b3c2818c.018681ff-559f-7183-19d1-7d898b4e1413", + "summary": { + "healthy": 1 + }, + "is_leaf": false, + "created_at": "2023-01-01T05:29:00+05:30", + "updated_at": "2023-01-01T05:29:00+05:30", + "parents": [ + "018681fe-f5aa-37e9-83f7-47b5b0232d5e" + ] + }, + { + "id": "018681ff-80ed-d10d-21ef-c74f152b085b", + "agent_id": "00000000-0000-0000-0000-000000000000", + "external_id": "dummy/logistics-api-574dc95b5d-mp64w", + "parent_id": "018681ff-559f-7183-19d1-7d898b4e1413", + "name": "logistics-api-574dc95b5d-mp64w", + "status": "healthy", + "tooltip": "Logistic API Pod", + "icon": "icon-kubernetes-pod", + "type": "KubernetesPod", + "properties": [ + { + "name": "memory", + "value": 100, + "unit": "bytes" + } + ], + "path": "018681fe-8156-4b91-d178-caf8b3c2818c.018681ff-559f-7183-19d1-7d898b4e1413", + "summary": { + "healthy": 1 + }, + "is_leaf": false, + "created_at": "2023-01-01T05:29:00+05:30", + "updated_at": "2023-01-01T05:29:00+05:30", + "parents": [ + "018681fe-f5aa-37e9-83f7-47b5b0232d5e" + ] + } + ] + } + ], + "healthStatuses": [ + "healthy" + ], + "teams": [], + "tags": null, + "types": [ + "KubernetesPod", + "KubernetesPods" + ] +} \ No newline at end of file diff --git a/tests/fixtures/expectations/topology_tree_with_sort.json b/tests/fixtures/expectations/topology_tree_with_sort.json new file mode 100644 index 00000000..cebd8a71 --- /dev/null +++ b/tests/fixtures/expectations/topology_tree_with_sort.json @@ -0,0 +1,117 @@ +{ + "components": [ + { + "id": "018681ff-559f-7183-19d1-7d898b4e1413", + "agent_id": "00000000-0000-0000-0000-000000000000", + "external_id": "dummy/pods", + "parent_id": "018681fe-8156-4b91-d178-caf8b3c2818c", + "name": "Pods", + "status": "healthy", + "tooltip": "Kubernetes Pods", + "icon": "icon-kubernetes-pod", + "type": "KubernetesPods", + "path": "018681fe-8156-4b91-d178-caf8b3c2818c", + "summary": { + "healthy": 3 + }, + "is_leaf": false, + "created_at": "2023-01-01T05:29:00+05:30", + "updated_at": "2023-01-01T05:29:00+05:30", + "components": [ + { + "id": "018681ff-80ed-d10d-21ef-c74f152b085b", + "agent_id": "00000000-0000-0000-0000-000000000000", + "external_id": "dummy/logistics-api-574dc95b5d-mp64w", + "parent_id": "018681ff-559f-7183-19d1-7d898b4e1413", + "name": "logistics-api-574dc95b5d-mp64w", + "status": "healthy", + "tooltip": "Logistic API Pod", + "icon": "icon-kubernetes-pod", + "type": "KubernetesPod", + "properties": [ + { + "name": "memory", + "value": 100, + "unit": "bytes" + } + ], + "path": "018681fe-8156-4b91-d178-caf8b3c2818c.018681ff-559f-7183-19d1-7d898b4e1413", + "summary": { + "healthy": 1 + }, + "is_leaf": false, + "created_at": "2023-01-01T05:29:00+05:30", + "updated_at": "2023-01-01T05:29:00+05:30", + "parents": [ + "018681fe-f5aa-37e9-83f7-47b5b0232d5e" + ] + }, + { + "id": "018681ff-b6c1-a14d-2fd4-8c7dac94cddd", + "agent_id": "00000000-0000-0000-0000-000000000000", + "external_id": "dummy/logistics-ui-676b85b87c-tjjcp", + "parent_id": "018681ff-559f-7183-19d1-7d898b4e1413", + "name": "logistics-ui-676b85b87c-tjjcp", + "status": "healthy", + "tooltip": "Logistic UI Pod", + "icon": "icon-kubernetes-pod", + "type": "KubernetesPod", + "properties": [ + { + "name": "memory", + "value": 200, + "unit": "bytes" + } + ], + "path": "018681fe-8156-4b91-d178-caf8b3c2818c.018681ff-559f-7183-19d1-7d898b4e1413", + "summary": { + "healthy": 1 + }, + "is_leaf": false, + "created_at": "2023-01-01T05:29:00+05:30", + "updated_at": "2023-01-01T05:29:00+05:30", + "parents": [ + "018681fe-f5aa-37e9-83f7-47b5b0232d5e" + ] + }, + { + "id": "018681ff-e578-a926-e366-d2dc0646eafa", + "agent_id": "00000000-0000-0000-0000-000000000000", + "external_id": "dummy/logistics-worker-79cb67d8f5-lr66n", + "parent_id": "018681ff-559f-7183-19d1-7d898b4e1413", + "name": "logistics-worker-79cb67d8f5-lr66n", + "status": "healthy", + "tooltip": "Logistic Worker Pod", + "icon": "icon-kubernetes-pod", + "type": "KubernetesPod", + "properties": [ + { + "name": "memory", + "value": 300, + "unit": "bytes" + } + ], + "path": "018681fe-8156-4b91-d178-caf8b3c2818c.018681ff-559f-7183-19d1-7d898b4e1413", + "summary": { + "healthy": 1 + }, + "is_leaf": false, + "created_at": "2023-01-01T05:29:00+05:30", + "updated_at": "2023-01-01T05:29:00+05:30", + "parents": [ + "018681ff-227e-4d71-b38e-0693cc862213" + ] + } + ] + } + ], + "healthStatuses": [ + "healthy" + ], + "teams": [], + "tags": null, + "types": [ + "KubernetesPod", + "KubernetesPods" + ] +} \ No newline at end of file diff --git a/tests/topology_test.go b/tests/topology_test.go index c304ad6d..3a016e24 100644 --- a/tests/topology_test.go +++ b/tests/topology_test.go @@ -119,4 +119,10 @@ var _ = ginkgo.Describe("Topology behavior", func() { ginkgo.It("Should test tree with agent ID filter", func() { testTopologyJSON(query.TopologyOptions{AgentID: dummy.GCPAgent.ID.String()}, "fixtures/expectations/topology_tree_with_agent_id.json") }) + + ginkgo.It("Should test tree with sort options", func() { + testTopologyJSON(query.TopologyOptions{ID: dummy.PodsComponent.ID.String(), SortBy: "field:memory"}, "fixtures/expectations/topology_tree_with_sort.json") + + testTopologyJSON(query.TopologyOptions{ID: dummy.PodsComponent.ID.String(), SortBy: "field:memory", SortOrder: "desc"}, "fixtures/expectations/topology_tree_with_desc_sort.json") + }) })