Backport of Displays Consul version of each nodes in UI nodes section into release/1.16.x (#18113)

## Backport

This PR is auto-generated from #17754 to be assessed for backporting due
to the inclusion of the label backport/1.16.


🚨
>**Warning** automatic cherry-pick of commits failed. If the first
commit failed,
you will see a blank no-op commit below. If at least one commit
succeeded, you
will see the cherry-picked commits up to, _not including_, the commit
where
the merge conflict occurred.

The person who merged in the original PR is:
@WenInCode
This person should manually cherry-pick the original PR into a new
backport PR,
and close this one when the manual backport PR is merged in.

> merge conflict error: unable to process merge commit:
"1c757b8a2c1160ad53421b7b8bd7f74b205c4b89", automatic backport requires
rebase workflow



The below text is copied from the body of the original PR.

---

fixes #17097 Consul version of each nodes in UI nodes section

@jkirschner-hashicorp @huikang @team @Maintainers

Updated consul version in the request to register consul.
Added this as Node MetaData.
Fetching this new metadata in UI

<img width="1512" alt="Screenshot 2023-06-15 at 4 21 33 PM"
src="https://github.com/hashicorp/consul/assets/3139634/94f7cf6b-701f-4230-b9f7-d8c4342d0737">

Also made this backward compatible and tested.

Backward compatible in this context means - If consul binary with above
PR changes is deployed to one of node, and if UI is run from this node,
then the version of not only current (upgraded) node is displayed in UI
, but also of older nodes given that they are consul servers only.
For older (non-server or client) nodes the version is not added in
NodeMeta Data and hence the version will not be displayed for them.
If a old node is consul server, the version will be displayed. As the
endpoint - "v1/internal/ui/nodes?dc=dc1" was already returning version
in service meta. This is made use of in current UI changes.

<img width="1480" alt="Screenshot 2023-06-16 at 6 58 32 PM"
src="https://github.com/hashicorp/consul/assets/3139634/257942f4-fbed-437d-a492-37849d2bec4c">




---

<details>
<summary> Overview of commits </summary>

- 931fdfc7ecdc26bb7cc20b698c5e14c1b65fcc6e -
b3e2ec1ccaca3832a088ffcac54257fa6653c6c1 -
8d0e9a54907039c09330c6cd7b9e761566af6856 -
04e5d88cca37821f6667be381c16aaa5958b5c92 -
28286a2e98f8cd66ef8593c2e2893b4db6080417 -
43e50ad38207952a9c4d04d45d08b6b8f71b31fe -
0cf1b7077cdf255596254d9dc1624a269c42b94d -
27f34ce1c2973591f75b1e38a81ccbe7cee6cee3 -
2ac76d62b8cbae76b1a903021aebb9b865e29d6e -
3d618df9ef1d10dd5056c8b1ed865839c553a0e0 -
1c757b8a2c1160ad53421b7b8bd7f74b205c4b89 -
23ce82b4cee8f74dd634dbe145313e9a56c0077d -
4dc1c9b4c5aafdb8883ef977dfa9b39da138b6cb -
85a12a92528bfa267a039a9bb258170be914abf7 -
25d30a3fa980d130a30d445d26d47ef2356cb553 -
7f1d6192dce3352e92307175848b89f91e728c24 -
5174cbff84b0795d4cb36eb8980d0d5336091ac9

</details>

---------

Co-authored-by: Vijay Srinivas <vijayraghav22@gmail.com>
Co-authored-by: John Murret <john.murret@hashicorp.com>
Co-authored-by: Jared Kirschner <85913323+jkirschner-hashicorp@users.noreply.github.com>
This commit is contained in:
hc-github-team-consul-core 2023-07-17 12:27:50 -05:00 committed by GitHub
parent c524e23540
commit ea93c7b29c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 477 additions and 129 deletions

3
.changelog/17754.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: consul version is displayed in nodes list with filtering and sorting based on versions
```

View File

@ -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)
}

View File

@ -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 {

View File

@ -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

View File

@ -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)
}

View File

@ -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])

View File

@ -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

View File

@ -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)

View File

@ -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: {}}

View File

@ -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
}

View File

@ -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) {

View File

@ -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)

View File

@ -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,
},

View File

@ -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

View File

@ -50,5 +50,17 @@ as |item index|>
{{item.Address}}
</dd>
</dl>
<dl>
<dt>
<span>ConsulVersion</span>
</dt>
<dd>
{{!-- Displaying consul version from node meta data --}}
{{#if item.Meta.consul-version}}
<FlightIcon class='w-4 h-4' @size='24' @name='consul-color' @stretched={{true}} />
<span>v{{item.Meta.consul-version}}</span>
{{/if}}
</dd>
</dl>
</BlockSlot>
</ListCollection>

View File

@ -1,136 +1,139 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
}}
<SearchBar
class="consul-node-search-bar"
...attributes
@filter={{@filter}}
>
<:status as |search|>
<SearchBar class="consul-node-search-bar" ...attributes @filter={{@filter}}>
<: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|}}
<search.RemoveFilter
aria-label={{t "common.ui.remove" item=(concat key " " value)}}
>
<dl>
<dt>{{key}}</dt>
<dd>{{value}}</dd>
</dl>
</search.RemoveFilter>
{{/let}}
</:status>
<:search as |search|>
<search.Search
@onsearch={{action @onsearch}}
@value={{@search}}
@placeholder={{t "common.search.search"}}
>
<search.Select
class="type-search-properties"
@position="right"
@onchange={{action @filter.searchproperty.change}}
@multiple={{true}}
@required={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
{{t "common.search.searchproperty"}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each @filter.searchproperty.default as |prop|}}
<Option @value={{prop}} @selected={{includes prop @filter.searchproperty.value}}>
{{t (concat "common.consul." (lowercase prop))}}
</Option>
{{/each}}
as |key value|}}
<search.RemoveFilter aria-label={{t "common.ui.remove" item=(concat key " " value)}}>
<dl>
<dt>{{key}}</dt>
<dd>{{value}}</dd>
</dl>
</search.RemoveFilter>
{{/let}}
</BlockSlot>
</search.Select>
</search.Search>
</:search>
<:filter as |search|>
<search.Select
class="type-status"
@position="left"
@onchange={{action @filter.status.change}}
@multiple={{true}}
as |components|>
</:status>
<:search as |search|>
<search.Search @onsearch={{action @onsearch}} @value={{@search}} @placeholder={{t "common.search.search" }}>
<search.Select class="type-search-properties" @position="right" @onchange={{action @filter.searchproperty.change}}
@multiple={{true}} @required={{true}} as |components|>
<BlockSlot @name="selected">
<span>
{{t "common.consul.status"}}
{{t "common.search.searchproperty"}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each (array "passing" "warning" "critical") as |state|}}
<Option class="value-{{state}}" @value={{state}} @selected={{includes state @filter.status.value}}>
{{t (concat "common.consul." state)
default=(array
(concat "common.search." state)
)
}}
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each @filter.searchproperty.default as |prop|}}
<Option @value={{prop}} @selected={{includes prop @filter.searchproperty.value}}>
{{t (concat "common.consul." (lowercase prop))}}
</Option>
{{/each}}
{{/let}}
{{/each}}
{{/let}}
</BlockSlot>
</search.Select>
</:filter>
<:sort as |search|>
<search.Select
class="type-sort"
data-test-sort-control
@position="right"
@onchange={{action @sort.change}}
@multiple={{false}}
@required={{true}}
</search.Search>
</:search>
<:filter as |search|>
<search.Select class="type-status" @position="left" @onchange={{action @filter.status.change}} @multiple={{true}} as
|components|>
<BlockSlot @name="selected">
<span>
{{t "common.consul.status"}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each (array "passing" "warning" "critical") as |state|}}
<Option class="value-{{state}}" @value={{state}} @selected={{includes state @filter.status.value}}>
{{t (concat "common.consul." state)
default=(array
(concat "common.search." state)
)
}}
</Option>
{{/each}}
{{/let}}
</BlockSlot>
</search.Select>
<search.Select class="type-version" @position="left" @onchange={{action @filter.version.change}} @multiple={{false}}
as |components|>
<BlockSlot @name="selected">
<span>
{{#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}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Optgroup @label={{t "common.consul.status"}}>
<Option @value="Status:asc" @selected={{eq "Status:asc" @sort.value}}>{{t "common.sort.status.asc"}}</Option>
<Option @value="Status:desc" @selected={{eq "Status:desc" @sort.value}}>{{t "common.sort.status.desc"}}</Option>
</Optgroup>
<Optgroup @label={{t "common.consul.node-name"}}>
<Option @value="Node:asc" @selected={{eq "Node:asc" @sort.value}}>{{t "common.sort.alpha.asc"}}</Option>
<Option @value="Node:desc" @selected={{eq "Node:desc" @sort.value}}>{{t "common.sort.alpha.desc"}}</Option>
</Optgroup>
{{/let}}
</BlockSlot>
</search.Select>
</:sort>
</SearchBar>
<BlockSlot @name="selected">
<span>
{{t "common.consul.version"}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each @versions as |version|}}
<Option @value={{version}} @selected={{includes version @filter.version.value}}>
{{concat version ".x" }}
</Option>
{{/each}}
{{/let}}
</BlockSlot>
</search.Select>
</:filter>
<:sort as |search|>
<search.Select class="type-sort" data-test-sort-control @position="right" @onchange={{action @sort.change}}
@multiple={{false}} @required={{true}} as |components|>
<BlockSlot @name="selected">
<span>
{{#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}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Optgroup @label={{t "common.consul.status" }}>
<Option @value="Status:asc" @selected={{eq "Status:asc" @sort.value}}>{{t "common.sort.status.asc"}}</Option>
<Option @value="Status:desc" @selected={{eq "Status:desc" @sort.value}}>{{t "common.sort.status.desc"}}
</Option>
</Optgroup>
<Optgroup @label={{t "common.consul.node-name" }}>
<Option @value="Node:asc" @selected={{eq "Node:asc" @sort.value}}>{{t "common.sort.alpha.asc"}}</Option>
<Option @value="Node:desc" @selected={{eq "Node:desc" @sort.value}}>{{t "common.sort.alpha.desc"}}</Option>
</Optgroup>
<Optgroup @label={{t "common.consul.version" }}>
<Option @value="Version:asc" @selected={{eq "Version:asc" @sort.value}}>{{t "common.sort.version.asc"}}
</Option>
<Option @value="Version:desc" @selected={{eq "Version:desc" @sort.value}}>{{t "common.sort.version.desc"}}
</Option>
</Optgroup>
{{/let}}
</BlockSlot>
</search.Select>
</:sort>
</SearchBar>

View File

@ -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 + '.'),
};

View File

@ -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'] ?? '';
}
}

View File

@ -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
}
}

View File

@ -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);
};

View File

@ -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|}}
<AppView>
@ -61,6 +66,7 @@
@onsearch={{action (mut search) value='target.value'}}
@sort={{sort}}
@filter={{filters}}
@versions={{versions}}
/>
{{/if}}
</BlockSlot>

View File

@ -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":[

View File

@ -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":[

View File

@ -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);
});
});

View File

@ -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: |

View File

@ -218,6 +218,7 @@
queryParams: {
sortBy: 'sort',
status: 'status',
version: 'version',
searchproperty: {
as: 'searchproperty',
empty: [['Node', 'Address', 'Meta', 'PeerName']],