diff --git a/.changelog/17754.txt b/.changelog/17754.txt new file mode 100644 index 000000000..56ab20dc2 --- /dev/null +++ b/.changelog/17754.txt @@ -0,0 +1,3 @@ +```release-note:feature +ui: consul version is displayed in nodes list with filtering and sorting based on versions +``` diff --git a/agent/agent.go b/agent/agent.go index fa75a1cd1..881b94209 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -3999,6 +3999,7 @@ func (a *Agent) loadMetadata(conf *config.RuntimeConfig) error { meta[k] = v } meta[structs.MetaSegmentKey] = conf.SegmentName + meta[structs.MetaConsulVersion] = conf.Version return a.State.LoadMetadata(meta) } diff --git a/agent/agent_endpoint_test.go b/agent/agent_endpoint_test.go index 9f4210ac8..0a37ae174 100644 --- a/agent/agent_endpoint_test.go +++ b/agent/agent_endpoint_test.go @@ -1508,7 +1508,8 @@ func TestAgent_Self(t *testing.T) { require.NoError(t, err) require.Equal(t, cs[a.config.SegmentName], val.Coord) - delete(val.Meta, structs.MetaSegmentKey) // Added later, not in config. + delete(val.Meta, structs.MetaSegmentKey) // Added later, not in config. + delete(val.Meta, structs.MetaConsulVersion) // Added later, not in config. require.Equal(t, a.config.NodeMeta, val.Meta) if tc.expectXDS { diff --git a/agent/consul/leader.go b/agent/consul/leader.go index c91655c5c..4bc1908d5 100644 --- a/agent/consul/leader.go +++ b/agent/consul/leader.go @@ -1087,6 +1087,13 @@ AFTER_CHECK: "partition", getSerfMemberEnterpriseMeta(member).PartitionOrDefault(), ) + // Get consul version from serf member + // add this as node meta in catalog register request + buildVersion, err := metadata.Build(&member) + if err != nil { + return err + } + // Register with the catalog. req := structs.RegisterRequest{ Datacenter: s.config.Datacenter, @@ -1102,6 +1109,9 @@ AFTER_CHECK: Output: structs.SerfCheckAliveOutput, }, EnterpriseMeta: *nodeEntMeta, + NodeMeta: map[string]string{ + structs.MetaConsulVersion: buildVersion.String(), + }, } if node != nil { req.TaggedAddresses = node.TaggedAddresses diff --git a/agent/consul/state/catalog.go b/agent/consul/state/catalog.go index 74efc3229..4e9fcf716 100644 --- a/agent/consul/state/catalog.go +++ b/agent/consul/state/catalog.go @@ -3450,6 +3450,13 @@ func parseNodes(tx ReadTxn, ws memdb.WatchSet, idx uint64, ws.AddWithLimit(watchLimit, services.WatchCh(), allServicesCh) for service := services.Next(); service != nil; service = services.Next() { ns := service.(*structs.ServiceNode).ToNodeService() + // If version isn't defined in node meta, set it from the Consul service meta + if _, ok := dump.Meta[structs.MetaConsulVersion]; !ok && ns.ID == "consul" && ns.Meta["version"] != "" { + if dump.Meta == nil { + dump.Meta = make(map[string]string) + } + dump.Meta[structs.MetaConsulVersion] = ns.Meta["version"] + } dump.Services = append(dump.Services, ns) } diff --git a/agent/consul/state/catalog_test.go b/agent/consul/state/catalog_test.go index 0de535c3b..e6b279580 100644 --- a/agent/consul/state/catalog_test.go +++ b/agent/consul/state/catalog_test.go @@ -4837,6 +4837,9 @@ func TestStateStore_NodeInfo_NodeDump(t *testing.T) { } // Register some nodes + // node1 is registered withOut any nodemeta, and a consul service with id + // 'consul' is added later with meta 'version'. The expected node must have + // meta 'consul-version' with same value testRegisterNode(t, s, 0, "node1") testRegisterNode(t, s, 1, "node2") @@ -4845,6 +4848,8 @@ func TestStateStore_NodeInfo_NodeDump(t *testing.T) { testRegisterService(t, s, 3, "node1", "service2") testRegisterService(t, s, 4, "node2", "service1") testRegisterService(t, s, 5, "node2", "service2") + // Register consul service with meta 'version' for node1 + testRegisterServiceWithMeta(t, s, 10, "node1", "consul", map[string]string{"version": "1.17.0"}) // Register service-level checks testRegisterCheck(t, s, 6, "node1", "service1", "check1", api.HealthPassing) @@ -4894,6 +4899,19 @@ func TestStateStore_NodeInfo_NodeDump(t *testing.T) { }, }, Services: []*structs.NodeService{ + { + ID: "consul", + Service: "consul", + Address: "1.1.1.1", + Meta: map[string]string{"version": "1.17.0"}, + Port: 1111, + Weights: &structs.Weights{Passing: 1, Warning: 1}, + RaftIndex: structs.RaftIndex{ + CreateIndex: 10, + ModifyIndex: 10, + }, + EnterpriseMeta: *structs.DefaultEnterpriseMetaInDefaultPartition(), + }, { ID: "service1", Service: "service1", @@ -4921,6 +4939,7 @@ func TestStateStore_NodeInfo_NodeDump(t *testing.T) { EnterpriseMeta: *structs.DefaultEnterpriseMetaInDefaultPartition(), }, }, + Meta: map[string]string{"consul-version": "1.17.0"}, }, &structs.NodeInfo{ Node: "node2", @@ -4988,7 +5007,7 @@ func TestStateStore_NodeInfo_NodeDump(t *testing.T) { if err != nil { t.Fatalf("err: %s", err) } - if idx != 9 { + if idx != 10 { t.Fatalf("bad index: %d", idx) } require.Len(t, dump, 1) @@ -4999,8 +5018,8 @@ func TestStateStore_NodeInfo_NodeDump(t *testing.T) { if err != nil { t.Fatalf("err: %s", err) } - if idx != 9 { - t.Fatalf("bad index: %d", 9) + if idx != 10 { + t.Fatalf("bad index: %d", idx) } if !reflect.DeepEqual(dump, expect) { t.Fatalf("bad: %#v", dump[0].Services[0]) diff --git a/agent/consul/state/state_store_test.go b/agent/consul/state/state_store_test.go index fef750253..587f15c03 100644 --- a/agent/consul/state/state_store_test.go +++ b/agent/consul/state/state_store_test.go @@ -189,6 +189,37 @@ func testRegisterServiceWithChangeOpts(t *testing.T, s *Store, idx uint64, nodeI return svc } +// testRegisterServiceWithMeta registers service with Meta passed as arg. +func testRegisterServiceWithMeta(t *testing.T, s *Store, idx uint64, nodeID, serviceID string, meta map[string]string, opts ...func(service *structs.NodeService)) *structs.NodeService { + svc := &structs.NodeService{ + ID: serviceID, + Service: serviceID, + Address: "1.1.1.1", + Port: 1111, + Meta: meta, + } + for _, o := range opts { + o(svc) + } + + if err := s.EnsureService(idx, nodeID, svc); err != nil { + t.Fatalf("err: %s", err) + } + + tx := s.db.Txn(false) + defer tx.Abort() + service, err := tx.First(tableServices, indexID, NodeServiceQuery{Node: nodeID, Service: serviceID, PeerName: svc.PeerName}) + if err != nil { + t.Fatalf("err: %s", err) + } + if result, ok := service.(*structs.ServiceNode); !ok || + result.Node != nodeID || + result.ServiceID != serviceID { + t.Fatalf("bad service: %#v", result) + } + return svc +} + // testRegisterService register a service with given transaction idx // If the service already exists, transaction number might not be increased // Use `testRegisterServiceWithChange()` if you want perform a registration that diff --git a/agent/local/state_test.go b/agent/local/state_test.go index 0a78f321f..4751352ec 100644 --- a/agent/local/state_test.go +++ b/agent/local/state_test.go @@ -189,7 +189,8 @@ func TestAgentAntiEntropy_Services(t *testing.T) { id := services.NodeServices.Node.ID addrs := services.NodeServices.Node.TaggedAddresses meta := services.NodeServices.Node.Meta - delete(meta, structs.MetaSegmentKey) // Added later, not in config. + delete(meta, structs.MetaSegmentKey) // Added later, not in config. + delete(meta, structs.MetaConsulVersion) // Added later, not in config. assert.Equal(t, a.Config.NodeID, id) assert.Equal(t, a.Config.TaggedAddresses, addrs) assert.Equal(t, unNilMap(a.Config.NodeMeta), meta) @@ -1355,7 +1356,8 @@ func TestAgentAntiEntropy_Checks(t *testing.T) { id := services.NodeServices.Node.ID addrs := services.NodeServices.Node.TaggedAddresses meta := services.NodeServices.Node.Meta - delete(meta, structs.MetaSegmentKey) // Added later, not in config. + delete(meta, structs.MetaSegmentKey) // Added later, not in config. + delete(meta, structs.MetaConsulVersion) // Added later, not in config. assert.Equal(r, a.Config.NodeID, id) assert.Equal(r, a.Config.TaggedAddresses, addrs) assert.Equal(r, unNilMap(a.Config.NodeMeta), meta) @@ -2016,7 +2018,8 @@ func TestAgentAntiEntropy_NodeInfo(t *testing.T) { addrs := services.NodeServices.Node.TaggedAddresses meta := services.NodeServices.Node.Meta nodeLocality := services.NodeServices.Node.Locality - delete(meta, structs.MetaSegmentKey) // Added later, not in config. + delete(meta, structs.MetaSegmentKey) // Added later, not in config. + delete(meta, structs.MetaConsulVersion) // Added later, not in config. require.Equal(t, a.Config.NodeID, id) require.Equal(t, a.Config.TaggedAddresses, addrs) require.Equal(t, a.Config.StructLocality(), nodeLocality) @@ -2041,7 +2044,8 @@ func TestAgentAntiEntropy_NodeInfo(t *testing.T) { addrs := services.NodeServices.Node.TaggedAddresses meta := services.NodeServices.Node.Meta nodeLocality := services.NodeServices.Node.Locality - delete(meta, structs.MetaSegmentKey) // Added later, not in config. + delete(meta, structs.MetaSegmentKey) // Added later, not in config. + delete(meta, structs.MetaConsulVersion) // Added later, not in config. require.Equal(t, nodeID, id) require.Equal(t, a.Config.TaggedAddresses, addrs) require.Equal(t, a.Config.StructLocality(), nodeLocality) diff --git a/agent/structs/structs.go b/agent/structs/structs.go index f56dd8f6a..fc9783fa8 100644 --- a/agent/structs/structs.go +++ b/agent/structs/structs.go @@ -220,6 +220,9 @@ const ( // WildcardSpecifier is the string which should be used for specifying a wildcard // The exact semantics of the wildcard is left up to the code where its used. WildcardSpecifier = "*" + + // MetaConsulVersion is the node metadata key used to store the node's consul version + MetaConsulVersion = "consul-version" ) var allowedConsulMetaKeysForMeshGateway = map[string]struct{}{MetaWANFederationKey: {}} diff --git a/agent/ui_endpoint.go b/agent/ui_endpoint.go index 3de9eac15..8f5184969 100644 --- a/agent/ui_endpoint.go +++ b/agent/ui_endpoint.go @@ -13,9 +13,12 @@ import ( "strings" "github.com/hashicorp/go-hclog" + "github.com/hashicorp/serf/serf" "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/config" + "github.com/hashicorp/consul/agent/consul" + "github.com/hashicorp/consul/agent/metadata" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/logging" @@ -110,7 +113,18 @@ RPC: return nil, err } + // Get version info for all serf members into a map of key-address,value-version. + // This logic of calling 'AgentMembersMapAddrVer()' and inserting version info in this func + // can be discarded in future releases ( may be after 3 or 4 minor releases), + // when all the nodes are registered with consul-version in nodemeta. + var err error + mapAddrVer, err := AgentMembersMapAddrVer(s, req) + if err != nil { + return nil, err + } + // Use empty list instead of nil + // Also check if consul-version exists in Meta, else add it for _, info := range out.Dump { if info.Services == nil { info.Services = make([]*structs.NodeService, 0) @@ -118,12 +132,24 @@ RPC: if info.Checks == nil { info.Checks = make([]*structs.HealthCheck, 0) } + // Check if Node Meta - 'consul-version' already exists by virtue of adding + // 'consul-version' during node registration itself. + // If not, get it from mapAddrVer. + if _, ok := info.Meta[structs.MetaConsulVersion]; !ok { + if _, okver := mapAddrVer[info.Address]; okver { + if info.Meta == nil { + info.Meta = make(map[string]string) + } + info.Meta[structs.MetaConsulVersion] = mapAddrVer[info.Address] + } + } } if out.Dump == nil { out.Dump = make(structs.NodeDump, 0) } // Use empty list instead of nil + // Also check if consul-version exists in Meta, else add it for _, info := range out.ImportedDump { if info.Services == nil { info.Services = make([]*structs.NodeService, 0) @@ -131,11 +157,60 @@ RPC: if info.Checks == nil { info.Checks = make([]*structs.HealthCheck, 0) } + // Check if Node Meta - 'consul-version' already exists by virtue of adding + // 'consul-version' during node registration itself. + // If not, get it from mapAddrVer. + if _, ok := info.Meta[structs.MetaConsulVersion]; !ok { + if _, okver := mapAddrVer[info.Address]; okver { + if info.Meta == nil { + info.Meta = make(map[string]string) + } + info.Meta[structs.MetaConsulVersion] = mapAddrVer[info.Address] + } + } } return append(out.Dump, out.ImportedDump...), nil } +// AgentMembersMapAddrVer is used to get version info from all serf members into a +// map of key-address,value-version. +func AgentMembersMapAddrVer(s *HTTPHandlers, req *http.Request) (map[string]string, error) { + var members []serf.Member + + //Get WAN Members + wanMembers := s.agent.WANMembers() + + //Get LAN Members + //Get the request partition and default to that of the agent. + entMeta := s.agent.AgentEnterpriseMeta() + if err := s.parseEntMetaPartition(req, entMeta); err != nil { + return nil, err + } + filter := consul.LANMemberFilter{ + Partition: entMeta.PartitionOrDefault(), + } + filter.AllSegments = true + lanMembers, err := s.agent.delegate.LANMembers(filter) + if err != nil { + return nil, err + } + + //aggregate members + members = append(wanMembers, lanMembers...) + + //create a map with key as IPv4 address and value as consul-version + mapAddrVer := make(map[string]string, len(members)) + for i := range members { + buildVersion, err := metadata.Build(&members[i]) + if err == nil { + mapAddrVer[members[i].Addr.String()] = buildVersion.String() + } + } + + return mapAddrVer, nil +} + // UINodeInfo is used to get info on a single node in a given datacenter. We return a // NodeInfo which provides overview information for the node func (s *HTTPHandlers) UINodeInfo(resp http.ResponseWriter, req *http.Request) (interface{}, error) { @@ -172,6 +247,16 @@ RPC: return nil, err } + // Get version info for all serf members into a map of key-address,value-version. + // This logic of calling 'AgentMembersMapAddrVer()' and inserting version info in this func + // can be discarded in future releases ( may be after 3 or 4 minor releases), + // when all the nodes are registered with consul-version in nodemeta. + var err error + mapAddrVer, err := AgentMembersMapAddrVer(s, req) + if err != nil { + return nil, err + } + // Return only the first entry if len(out.Dump) > 0 { info := out.Dump[0] @@ -181,6 +266,17 @@ RPC: if info.Checks == nil { info.Checks = make([]*structs.HealthCheck, 0) } + // Check if Node Meta - 'consul-version' already exists by virtue of adding + // 'consul-version' during node registration itself. + // If not, get it from mapAddrVer. + if _, ok := info.Meta[structs.MetaConsulVersion]; !ok { + if _, okver := mapAddrVer[info.Address]; okver { + if info.Meta == nil { + info.Meta = make(map[string]string) + } + info.Meta[structs.MetaConsulVersion] = mapAddrVer[info.Address] + } + } return info, nil } diff --git a/agent/ui_endpoint_test.go b/agent/ui_endpoint_test.go index 5fc2e06d3..f6810db80 100644 --- a/agent/ui_endpoint_test.go +++ b/agent/ui_endpoint_test.go @@ -162,6 +162,9 @@ func TestUINodes(t *testing.T) { require.Len(t, nodes[2].Services, 0) require.NotNil(t, nodes[1].Checks) require.Len(t, nodes[2].Services, 0) + + // check for consul-version in node meta + require.Equal(t, nodes[0].Meta[structs.MetaConsulVersion], a.Config.Version) } func TestUINodes_Filter(t *testing.T) { @@ -260,6 +263,9 @@ func TestUINodeInfo(t *testing.T) { node.Checks == nil || len(node.Checks) != 0 { t.Fatalf("bad: %v", node) } + + // check for consul-version in node meta + require.Equal(t, node.Meta[structs.MetaConsulVersion], a.Config.Version) } func TestUIServices(t *testing.T) { diff --git a/api/catalog_test.go b/api/catalog_test.go index 622669135..2b0a4097b 100644 --- a/api/catalog_test.go +++ b/api/catalog_test.go @@ -65,6 +65,7 @@ func TestAPI_CatalogNodes(t *testing.T) { }, Meta: map[string]string{ "consul-network-segment": "", + "consul-version": s.Config.Version, }, } require.Equal(r, want, got) diff --git a/api/txn_test.go b/api/txn_test.go index 975f3e381..ea454976d 100644 --- a/api/txn_test.go +++ b/api/txn_test.go @@ -361,7 +361,10 @@ func TestAPI_ClientTxn(t *testing.T) { "wan": s.Config.Bind, "wan_ipv4": s.Config.Bind, }, - Meta: map[string]string{"consul-network-segment": ""}, + Meta: map[string]string{ + "consul-network-segment": "", + "consul-version": s.Config.Version, + }, CreateIndex: ret.Results[1].Node.CreateIndex, ModifyIndex: ret.Results[1].Node.ModifyIndex, }, diff --git a/sdk/testutil/server.go b/sdk/testutil/server.go index d00850d5e..a20f95123 100644 --- a/sdk/testutil/server.go +++ b/sdk/testutil/server.go @@ -130,6 +130,7 @@ type TestServerConfig struct { Args []string `json:"-"` ReturnPorts func() `json:"-"` Audit *TestAuditConfig `json:"audit,omitempty"` + Version string `json:"version,omitempty"` } type TestACLs struct { @@ -212,6 +213,7 @@ func defaultServerConfig(t TestingTB, consulVersion *version.Version) *TestServe Stdout: logBuffer, Stderr: logBuffer, Peering: &TestPeeringConfig{Enabled: true}, + Version: consulVersion.String(), } // Add version-specific tweaks diff --git a/ui/packages/consul-ui/app/components/consul/node/list/index.hbs b/ui/packages/consul-ui/app/components/consul/node/list/index.hbs index c1392bd68..f57d7d5ab 100644 --- a/ui/packages/consul-ui/app/components/consul/node/list/index.hbs +++ b/ui/packages/consul-ui/app/components/consul/node/list/index.hbs @@ -50,5 +50,17 @@ as |item index|> {{item.Address}} +
+
+ ConsulVersion +
+
+ {{!-- Displaying consul version from node meta data --}} + {{#if item.Meta.consul-version}} + + v{{item.Meta.consul-version}} + {{/if}} +
+
diff --git a/ui/packages/consul-ui/app/components/consul/node/search-bar/index.hbs b/ui/packages/consul-ui/app/components/consul/node/search-bar/index.hbs index 3aa868181..e22e6b2b0 100644 --- a/ui/packages/consul-ui/app/components/consul/node/search-bar/index.hbs +++ b/ui/packages/consul-ui/app/components/consul/node/search-bar/index.hbs @@ -1,136 +1,139 @@ {{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: MPL-2.0 +Copyright (c) HashiCorp, Inc. +SPDX-License-Identifier: MPL-2.0 }} - - <:status as |search|> + + <:status as |search|> -{{#let + {{#let - (t (concat "components.consul.node.search-bar." search.status.key) - default=(array - (concat "common.search." search.status.key) - (concat "common.consul." search.status.key) - ) - ) + (t (concat "components.consul.node.search-bar." search.status.key) + default=(array + (concat "common.search." search.status.key) + (concat "common.consul." search.status.key) + ) + ) - (t (concat "components.consul.node.search-bar." search.status.value) - default=(array - (concat "common.search." search.status.value) - (concat "common.consul." search.status.value) - (concat "common.brand." search.status.value) - ) - ) + (if search.status.value + search.status.value + (t (concat "components.consul.node.search-bar." search.status.value) + default=(array + (concat "common.search." search.status.value) + (concat "common.consul." search.status.value) + (concat "common.brand." search.status.value) + )) + ) -as |key value|}} - -
-
{{key}}
-
{{value}}
-
-
-{{/let}} - - - <:search as |search|> - - - - - {{t "common.search.searchproperty"}} - - - - {{#let components.Optgroup components.Option as |Optgroup Option|}} - {{#each @filter.searchproperty.default as |prop|}} - - {{/each}} + as |key value|}} + +
+
{{key}}
+
{{value}}
+
+
{{/let}} -
-
-
- - <:filter as |search|> - + + + <:search as |search|> + + - {{t "common.consul.status"}} + {{t "common.search.searchproperty"}} - {{#let components.Optgroup components.Option as |Optgroup Option|}} - {{#each (array "passing" "warning" "critical") as |state|}} - - {{/each}} - {{/let}} + {{/each}} + {{/let}} - - <:sort as |search|> - + + <:filter as |search|> + + + + {{t "common.consul.status"}} + + + + {{#let components.Optgroup components.Option as |Optgroup Option|}} + {{#each (array "passing" "warning" "critical") as |state|}} + + {{/each}} + {{/let}} + + + - - - {{#let (from-entries (array - (array "Node:asc" (t "common.sort.alpha.asc")) - (array "Node:desc" (t "common.sort.alpha.desc")) - (array "Status:asc" (t "common.sort.status.asc")) - (array "Status:desc" (t "common.sort.status.desc")) - )) - as |selectable| - }} - {{get selectable @sort.value}} - {{/let}} - - - - {{#let components.Optgroup components.Option as |Optgroup Option|}} - - - - - - - - - {{/let}} - - - -
\ No newline at end of file + + + {{t "common.consul.version"}} + + + + {{#let components.Optgroup components.Option as |Optgroup Option|}} + {{#each @versions as |version|}} + + {{/each}} + {{/let}} + + + + <:sort as |search|> + + + + {{#let (from-entries (array + (array "Node:asc" (t "common.sort.alpha.asc")) + (array "Node:desc" (t "common.sort.alpha.desc")) + (array "Status:asc" (t "common.sort.status.asc")) + (array "Status:desc" (t "common.sort.status.desc")) + (array "Version:asc" (t "common.sort.version.asc")) + (array "Version:desc" (t "common.sort.version.desc")) + )) + as |selectable| + }} + {{get selectable @sort.value}} + {{/let}} + + + + {{#let components.Optgroup components.Option as |Optgroup Option|}} + + + + + + + + + + + + + {{/let}} + + + +
diff --git a/ui/packages/consul-ui/app/filter/predicates/node.js b/ui/packages/consul-ui/app/filter/predicates/node.js index b5c752ab9..83eb3c98e 100644 --- a/ui/packages/consul-ui/app/filter/predicates/node.js +++ b/ui/packages/consul-ui/app/filter/predicates/node.js @@ -9,4 +9,5 @@ export default { warning: (item, value) => item.Status === value, critical: (item, value) => item.Status === value, }, + version: (item, value) => item.Version.includes(value + '.'), }; diff --git a/ui/packages/consul-ui/app/models/node.js b/ui/packages/consul-ui/app/models/node.js index e56f3e1ff..b95ec2830 100644 --- a/ui/packages/consul-ui/app/models/node.js +++ b/ui/packages/consul-ui/app/models/node.js @@ -67,4 +67,9 @@ export default class Node extends Model { get ChecksWarning() { return this.NodeChecks.filter((item) => item.Status === 'warning').length; } + + @computed('Meta') + get Version() { + return this.Meta?.['consul-version'] ?? ''; + } } diff --git a/ui/packages/consul-ui/app/serializers/application.js b/ui/packages/consul-ui/app/serializers/application.js index 2e6bac137..d9e58b0ab 100644 --- a/ui/packages/consul-ui/app/serializers/application.js +++ b/ui/packages/consul-ui/app/serializers/application.js @@ -156,6 +156,10 @@ export default class ApplicationSerializer extends Serializer { // ember-data methods so we have the opportunity to do this on a per-model // level const meta = this.normalizeMeta(store, modelClass, normalizedPayload, id, requestType); + // get distinct consul versions from list and add it as meta + if (modelClass.modelName === 'node' && requestType === 'query') { + meta.versions = this.getDistinctConsulVersions(normalizedPayload); + } if (requestType !== 'query') { normalizedPayload.meta = meta; } @@ -215,4 +219,45 @@ export default class ApplicationSerializer extends Serializer { normalizePayload(payload, id, requestType) { return payload; } + + // getDistinctConsulVersions will be called only for nodes and query request type + // the list of versions is to be added as meta to resp, without changing original response structure + // hence this function is added in application.js + getDistinctConsulVersions(payload) { + // create a Set and add version with only major.minor : ex-1.24.6 as 1.24 + let versionSet = new Set(); + payload.forEach(function (item) { + if (item.Meta && item.Meta['consul-version'] !== '') { + const split = item.Meta['consul-version'].split('.'); + versionSet.add(split[0] + '.' + split[1]); + } + }); + + const versionArray = Array.from(versionSet); + + // Sort the array in descending order using a custom comparison function + versionArray.sort((a, b) => { + // Split the versions into arrays of numbers + const versionA = a.split('.').map((part) => { + const number = Number(part); + return isNaN(number) ? 0 : number; + }); + const versionB = b.split('.').map((part) => { + const number = Number(part); + return isNaN(number) ? 0 : number; + }); + + const minLength = Math.min(versionA.length, versionB.length); + + // start with comparing major version num, if equal then compare minor + for (let i = 0; i < minLength; i++) { + if (versionA[i] !== versionB[i]) { + return versionB[i] - versionA[i]; + } + } + return versionB.length - versionA.length; + }); + + return versionArray; //sorted array + } } diff --git a/ui/packages/consul-ui/app/sort/comparators/node.js b/ui/packages/consul-ui/app/sort/comparators/node.js index c584456e3..fb7055075 100644 --- a/ui/packages/consul-ui/app/sort/comparators/node.js +++ b/ui/packages/consul-ui/app/sort/comparators/node.js @@ -38,6 +38,43 @@ export default ({ properties }) => return 0; } }; + } else if (key.startsWith('Version:')) { + return function (itemA, itemB) { + const [, dir] = key.split(':'); + let a, b; + if (dir === 'asc') { + a = itemA; + b = itemB; + } else { + b = itemA; + a = itemB; + } + + // Split the versions into arrays of numbers + const versionA = a.Version.split('.').map((part) => { + const number = Number(part); + return isNaN(number) ? 0 : number; + }); + const versionB = b.Version.split('.').map((part) => { + const number = Number(part); + return isNaN(number) ? 0 : number; + }); + + const minLength = Math.min(versionA.length, versionB.length); + + for (let i = 0; i < minLength; i++) { + const diff = versionA[i] - versionB[i]; + switch (true) { + case diff > 0: + return 1; + case diff < 0: + return -1; + } + } + + return versionA.length - versionB.length; + }; } + return properties(['Node'])(key); }; diff --git a/ui/packages/consul-ui/app/templates/dc/nodes/index.hbs b/ui/packages/consul-ui/app/templates/dc/nodes/index.hbs index 4d0eb2e3a..56f7676c4 100644 --- a/ui/packages/consul-ui/app/templates/dc/nodes/index.hbs +++ b/ui/packages/consul-ui/app/templates/dc/nodes/index.hbs @@ -40,10 +40,15 @@ change=(action (mut searchproperty) value='target.selectedItems') default=this._searchProperties ) + version=(hash + value=(if this.version (split this.version ',') undefined) + change=(action (mut this.version) value='target.selectedItems') + ) ) api.data leader.data - as |sort filters items leader| + api.data.meta.versions + as |sort filters items leader versions| }} {{#let (reject-by 'Meta.synthetic-node' items) as |filtered|}} @@ -61,6 +66,7 @@ @onsearch={{action (mut search) value='target.value'}} @sort={{sort}} @filter={{filters}} + @versions={{versions}} /> {{/if}} diff --git a/ui/packages/consul-ui/mock-api/v1/internal/ui/node/_ b/ui/packages/consul-ui/mock-api/v1/internal/ui/node/_ index 27afd9c8e..2adf163f7 100644 --- a/ui/packages/consul-ui/mock-api/v1/internal/ui/node/_ +++ b/ui/packages/consul-ui/mock-api/v1/internal/ui/node/_ @@ -30,6 +30,7 @@ return ` "TaggedAddresses":{"lan":"${ip}","wan":"${ip}"}, "Meta":{ "consul-network-segment":"", + "consul-version": "${env('CONSUL_VERSION') ? fake.helpers.randomize([env('CONSUL_VERSION'),"1.10.4","1.15.2", "1.17.8","1.7.2","1.12.4", "1.17.2","1.0.9","2.0.2"]) : fake.helpers.randomize(["1.10.4","1.15.2", "1.17.8","1.7.2","1.12.4", "1.17.2","1.0.9","2.0.2"]) }", "consul-dashboard-url": "${fake.internet.protocol()}://${fake.internet.domainName()}/?id={{Node}}" }, "Services":[ diff --git a/ui/packages/consul-ui/mock-api/v1/internal/ui/nodes b/ui/packages/consul-ui/mock-api/v1/internal/ui/nodes index 2740d824a..de64b0bb8 100644 --- a/ui/packages/consul-ui/mock-api/v1/internal/ui/nodes +++ b/ui/packages/consul-ui/mock-api/v1/internal/ui/nodes @@ -25,6 +25,7 @@ }, "Meta": { "consul-network-segment":"", + "consul-version": "${env('CONSUL_VERSION') ? fake.helpers.randomize([env('CONSUL_VERSION'),"1.10.4","1.15.2", "1.17.8","1.7.2","1.12.4", "1.17.2","1.0.9","2.0.2"]) : fake.helpers.randomize(["1.10.4","1.15.2", "1.17.8","1.7.2","1.12.4", "1.17.2","1.0.9","2.0.2"]) }", "synthetic-node": ${env('CONSUL_AGENTLESS_ENABLED') ? fake.helpers.randomize([true, false, false, false]) : false} }, "Services":[ diff --git a/ui/packages/consul-ui/tests/unit/sort/comparators/node-test.js b/ui/packages/consul-ui/tests/unit/sort/comparators/node-test.js new file mode 100644 index 000000000..9b5c5ea75 --- /dev/null +++ b/ui/packages/consul-ui/tests/unit/sort/comparators/node-test.js @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import comparators from 'consul-ui/sort/comparators/node'; +import { properties } from 'consul-ui/services/sort'; +import { module, test } from 'qunit'; + +module('Unit | Sort | Comparator | node', function () { + const comparator = comparators({ properties }); + test('items are sorted by a fake Version', function (assert) { + const items = [ + { + Version: '2.24.1', + }, + { + Version: '1.12.6', + }, + { + Version: '2.09.3', + }, + ]; + const comp = comparator('Version:asc'); + assert.equal(typeof comp, 'function'); + + const expected = [ + { + Version: '1.12.6', + }, + { + Version: '2.09.3', + }, + { + Version: '2.24.1', + }, + ]; + let actual = items.sort(comp); + assert.deepEqual(actual, expected); + + expected.reverse(); + actual = items.sort(comparator('Version:desc')); + assert.deepEqual(actual, expected); + }); +}); diff --git a/ui/packages/consul-ui/translations/common/en-us.yaml b/ui/packages/consul-ui/translations/common/en-us.yaml index fc7f542fe..b0dc3bffe 100644 --- a/ui/packages/consul-ui/translations/common/en-us.yaml +++ b/ui/packages/consul-ui/translations/common/en-us.yaml @@ -53,6 +53,7 @@ consul: redundancyzone: Redundancy zone peername: Peer partition: Admin Partitions + version: Version search: search: Search searchproperty: Search Across @@ -77,6 +78,9 @@ sort: status: asc: Unhealthy to Healthy desc: Healthy to Unhealthy + version: + asc: Oldest to Latest + desc: Latest to Oldest validations: dns-hostname: help: | diff --git a/ui/packages/consul-ui/vendor/consul-ui/routes.js b/ui/packages/consul-ui/vendor/consul-ui/routes.js index 79aadcd6a..ec25fb0a2 100644 --- a/ui/packages/consul-ui/vendor/consul-ui/routes.js +++ b/ui/packages/consul-ui/vendor/consul-ui/routes.js @@ -218,6 +218,7 @@ queryParams: { sortBy: 'sort', status: 'status', + version: 'version', searchproperty: { as: 'searchproperty', empty: [['Node', 'Address', 'Meta', 'PeerName']],