Merge branch 'feature/related-peers' into develop

This commit is contained in:
Matthias Hannig 2019-10-10 22:02:05 +02:00
commit 63c8b7478c
No known key found for this signature in database
GPG Key ID: 62E226E47DDCE58D
16 changed files with 616 additions and 37 deletions

View File

@ -30,8 +30,8 @@ import (
// Routes /api/v1/routeservers/:id/neighbors/:neighborId/routes
//
// Querying
// LookupPrefix /api/v1/routeservers/:id/lookup/prefix?q=<prefix>
//
// LookupPrefix /api/v1/lookup/prefix?q=<prefix>
// LookupNeighbor /api/v1/lookup/neighbor?asn=1235
type apiEndpoint func(*http.Request, httprouter.Params) (api.Response, error)
@ -111,6 +111,8 @@ func apiRegisterEndpoints(router *httprouter.Router) error {
if AliceConfig.Server.EnablePrefixLookup == true {
router.GET("/api/v1/lookup/prefix",
endpoint(apiLookupPrefixGlobal))
router.GET("/api/v1/lookup/neighbors",
endpoint(apiLookupNeighborsGlobal))
}
return nil

View File

@ -1,6 +1,7 @@
package api
import (
"strings"
"time"
)
@ -22,6 +23,7 @@ type Neighbour struct {
RoutesAccepted int `json:"routes_accepted"`
Uptime time.Duration `json:"uptime"`
LastError string `json:"last_error"`
RouteServerId string `json:"routeserver_id"`
// Original response
Details map[string]interface{} `json:"details"`
@ -45,6 +47,34 @@ type NeighboursResponse struct {
Neighbours Neighbours `json:"neighbours"`
}
// Implement Filterable interface
func (self *Neighbour) MatchSourceId(id string) bool {
return self.RouteServerId == id
}
func (self *Neighbour) MatchAsn(asn int) bool {
return self.Asn == asn
}
func (self *Neighbour) MatchCommunity(_community Community) bool {
return true // Ignore
}
func (self *Neighbour) MatchExtCommunity(_community Community) bool {
return true // Ignore
}
func (self *Neighbour) MatchLargeCommunity(_community Community) bool {
return true // Ignore
}
func (self *Neighbour) MatchName(name string) bool {
name = strings.ToLower(name)
neighName := strings.ToLower(self.Description)
return strings.Contains(neighName, name)
}
// Neighbours response is cacheable
func (self *NeighboursResponse) CacheTtl() time.Duration {
now := time.Now().UTC()
@ -53,7 +83,6 @@ func (self *NeighboursResponse) CacheTtl() time.Duration {
type NeighboursLookupResults map[string]Neighbours
type NeighboursStatus []*NeighbourStatus
type NeighbourStatus struct {

View File

@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/url"
"strconv"
)
const (
@ -541,3 +542,56 @@ func (self *SearchFilters) MergeProperties(other *SearchFilters) {
}
}
}
// The above filters apply for now for routes.
// We are using a slightly simpler solution for neighbor queries.
// At least for the time beeing.
type NeighborFilter struct {
name string
asn int
}
/*
Get neighbor filters from query parameters.
Right now we support filtering by name (partial match)
and ASN.
The latter is used to find related peers on all route servers.
*/
func NeighborFilterFromQuery(q url.Values) *NeighborFilter {
asn := 0
name := q.Get("name")
asnVal := q.Get("asn")
if asnVal != "" {
asn, _ = strconv.Atoi(asnVal)
}
filter := &NeighborFilter{
name: name,
asn: asn,
}
return filter
}
/*
Decode query values from string.
This is intendet as a helper method to make testing easier.
*/
func NeighborFilterFromQueryString(q string) *NeighborFilter {
values, _ := url.ParseQuery(q)
return NeighborFilterFromQuery(values)
}
/*
Match neighbor with filter: Check if the neighbor
in question has the required parameters.
*/
func (self *NeighborFilter) Match(neighbor *Neighbour) bool {
if self.name != "" && neighbor.MatchName(self.name) {
return true
}
if self.asn > 0 && neighbor.MatchAsn(self.asn) {
return true
}
return false
}

View File

@ -594,3 +594,63 @@ func TestSearchFiltersMergeProperties(t *testing.T) {
}
}
func TestNeighborFilterMatch(t *testing.T) {
n1 := &Neighbour{
Asn: 2342,
Description: "Foo Networks AB",
}
n2 := &Neighbour{
Asn: 42,
Description: "Bar Communications Inc.",
}
filter := &NeighborFilter{
asn: 42,
}
if filter.Match(n1) != false {
t.Error("Expected n1 not to match filter")
}
if filter.Match(n2) == false {
t.Error("Expected n2 to match filter")
}
filter = &NeighborFilter{
name: "network",
}
if filter.Match(n1) == false {
t.Error("Expected n1 to match filter")
}
if filter.Match(n2) != false {
t.Error("Expected n2 not to match filter")
}
filter = &NeighborFilter{
asn: 42,
name: "network",
}
if filter.Match(n1) == false || filter.Match(n2) == false {
t.Error("Expected filter to match both neighbors.")
}
}
func TestNeighborFilterFromQuery(t *testing.T) {
query := "asn=2342&name=foo"
filter := NeighborFilterFromQueryString(query)
if filter.asn != 2342 {
t.Error("Unexpected asn filter:", filter.asn)
}
if filter.name != "foo" {
t.Error("Unexpected name filter:", filter.name)
}
filter = NeighborFilterFromQueryString(values)
if filter.asn != 0 {
t.Error("Unexpected asn:", filter.asn)
}
if filter.name != "" {
t.Error("Unexpected name:", filter.name)
}
}

View File

@ -9,7 +9,10 @@ import (
)
// Handle get neighbors on routeserver
func apiNeighborsList(_req *http.Request, params httprouter.Params) (api.Response, error) {
func apiNeighborsList(
_req *http.Request,
params httprouter.Params,
) (api.Response, error) {
rsId, err := validateSourceId(params.ByName("id"))
if err != nil {
return nil, err

View File

@ -14,7 +14,7 @@ func apiLookupPrefixGlobal(
req *http.Request,
params httprouter.Params,
) (api.Response, error) {
// TODO: This function is too long
// TODO: This function is way too long
// Get prefix to query
q, err := validateQueryString(req, "q")
@ -134,3 +134,27 @@ func apiLookupPrefixGlobal(
return response, nil
}
func apiLookupNeighborsGlobal(
req *http.Request,
params httprouter.Params,
) (api.Response, error) {
// Query neighbors store
filter := api.NeighborFilterFromQuery(req.URL.Query())
neighbors := AliceNeighboursStore.FilterNeighbors(filter)
sort.Sort(neighbors)
// Make response
response := &api.NeighboursResponse{
Api: api.ApiStatus{
CacheStatus: api.CacheStatus{
CachedAt: AliceNeighboursStore.CachedAt(),
},
ResultFromCache: true, // You would not have guessed.
Ttl: AliceNeighboursStore.CacheTtl(),
},
Neighbours: neighbors,
}
return response, nil
}

View File

@ -20,6 +20,7 @@ type NeighboursStore struct {
statusMap map[string]StoreStatus
refreshInterval time.Duration
refreshNeighborStatus bool
lastRefresh time.Time
sync.RWMutex
}
@ -52,10 +53,10 @@ func NewNeighboursStore(config *Config) *NeighboursStore {
refreshNeighborStatus := config.Server.EnableNeighborsStatusRefresh
store := &NeighboursStore{
neighboursMap: neighboursMap,
statusMap: statusMap,
configMap: configMap,
refreshInterval: refreshInterval,
neighboursMap: neighboursMap,
statusMap: statusMap,
configMap: configMap,
refreshInterval: refreshInterval,
refreshNeighborStatus: refreshNeighborStatus,
}
return store
@ -153,6 +154,7 @@ func (self *NeighboursStore) update() {
LastRefresh: time.Now(),
State: STATE_READY,
}
self.lastRefresh = time.Now().UTC()
self.Unlock()
successCount++
}
@ -257,6 +259,46 @@ func (self *NeighboursStore) LookupNeighbours(
return results
}
/*
Filter neighbors from a single route server.
*/
func (self *NeighboursStore) FilterNeighborsAt(
sourceId string,
filter *api.NeighborFilter,
) api.Neighbours {
results := []*api.Neighbour{}
self.RLock()
neighbors := self.neighboursMap[sourceId]
self.RUnlock()
// Apply filters
for _, neighbor := range neighbors {
if filter.Match(neighbor) {
results = append(results, neighbor)
}
}
return results
}
/*
Filter neighbors by name or by ASN.
Collect results from all routeservers.
*/
func (self *NeighboursStore) FilterNeighbors(
filter *api.NeighborFilter,
) api.Neighbours {
results := []*api.Neighbour{}
// Get neighbors from all routeservers
for sourceId, _ := range self.neighboursMap {
rsResults := self.FilterNeighborsAt(sourceId, filter)
results = append(results, rsResults...)
}
return results
}
// Build some stats for monitoring
func (self *NeighboursStore) Stats() NeighboursStoreStats {
totalNeighbours := 0
@ -282,3 +324,11 @@ func (self *NeighboursStore) Stats() NeighboursStoreStats {
}
return storeStats
}
func (self *NeighboursStore) CachedAt() time.Time {
return self.lastRefresh
}
func (self *NeighboursStore) CacheTtl() time.Time {
return self.lastRefresh.Add(self.refreshInterval)
}

View File

@ -25,27 +25,37 @@ func makeTestNeighboursStore() *NeighboursStore {
// Populate neighbours
rs1 := NeighboursIndex{
"ID2233_AS2342": &api.Neighbour{
Id: "ID2233_AS2342",
Description: "PEER AS2342 192.9.23.42 Customer Peer 1",
Id: "ID2233_AS2342",
Asn: 2342,
Description: "PEER AS2342 192.9.23.42 Customer Peer 1",
RouteServerId: "rs1",
},
"ID2233_AS2343": &api.Neighbour{
Id: "ID2233_AS2343",
Description: "PEER AS2343 192.9.23.43 Different Peer 1",
Id: "ID2233_AS2343",
Asn: 2343,
Description: "PEER AS2343 192.9.23.43 Different Peer 1",
RouteServerId: "rs1",
},
"ID2233_AS2344": &api.Neighbour{
Id: "ID2233_AS2344",
Description: "PEER AS2344 192.9.23.44 3rd Peer from the sun",
Id: "ID2233_AS2344",
Asn: 2344,
Description: "PEER AS2344 192.9.23.44 3rd Peer from the sun",
RouteServerId: "rs1",
},
}
rs2 := NeighboursIndex{
"ID2233_AS2342": &api.Neighbour{
Id: "ID2233_AS2342",
Description: "PEER AS2342 192.9.23.42 Customer Peer 1",
Id: "ID2233_AS2342",
Asn: 2342,
Description: "PEER AS2342 192.9.23.42 Customer Peer 1",
RouteServerId: "rs2",
},
"ID2233_AS4223": &api.Neighbour{
Id: "ID2233_AS4223",
Description: "PEER AS4223 192.9.42.23 Cloudfoo Inc.",
Id: "ID2233_AS4223",
Asn: 4223,
Description: "PEER AS4223 192.9.42.23 Cloudfoo Inc.",
RouteServerId: "rs2",
},
}
@ -158,3 +168,19 @@ func TestNeighbourLookup(t *testing.T) {
t.Error("Wrong peer in lookup response")
}
}
func TestNeighborFilter(t *testing.T) {
store := makeTestNeighboursStore()
filter := api.NeighborFilterFromQueryString("asn=2342")
neighbors := store.FilterNeighbors(filter)
if len(neighbors) != 2 {
t.Error("Expected two results")
}
filter = api.NeighborFilterFromQueryString("")
neighbors = store.FilterNeighbors(filter)
if len(neighbors) != 0 {
t.Error("Expected empty result set")
}
}

View File

@ -152,6 +152,7 @@ func parseRelativeServerTime(uptime interface{}, config Config) time.Duration {
// Parse neighbours response
func parseNeighbours(bird ClientResponse, config Config) (api.Neighbours, error) {
rsId := config.Id
neighbours := api.Neighbours{}
protocols := bird["protocols"].(map[string]interface{})
@ -176,11 +177,12 @@ func parseNeighbours(bird ClientResponse, config Config) (api.Neighbours, error)
neighbour := &api.Neighbour{
Id: protocolId,
Address: mustString(protocol["neighbor_address"], "error"),
Asn: mustInt(protocol["neighbor_as"], 0),
State: strings.ToLower(mustString(protocol["state"], "unknown")),
Address: mustString(protocol["neighbor_address"], "error"),
Asn: mustInt(protocol["neighbor_as"], 0),
State: strings.ToLower(
mustString(protocol["state"], "unknown")),
Description: mustString(protocol["description"], "no description"),
//TODO make these changes configurable
RoutesReceived: mustInt(routesReceived, 0),
RoutesAccepted: mustInt(routes["imported"], 0),
RoutesFiltered: mustInt(routes["filtered"], 0),
@ -190,6 +192,8 @@ func parseNeighbours(bird ClientResponse, config Config) (api.Neighbours, error)
Uptime: uptime,
LastError: lastError,
RouteServerId: rsId,
Details: protocol,
}

View File

@ -167,6 +167,7 @@ func (gobgp *GoBGP) Neighbours() (*api.NeighboursResponse, error) {
neigh.Description = _resp.Peer.Conf.Description
neigh.Id = PeerHash(_resp.Peer)
neigh.RouteServerId = gobgp.config.Id
response.Neighbours = append(response.Neighbours, &neigh)
for _, afiSafi := range _resp.Peer.AfiSafis {

View File

@ -54,4 +54,3 @@
}
}

View File

@ -311,6 +311,100 @@ $labelOffsetEnd: -70px;
}
}
// Related Peers Box
.card-related-peers {
h2 {
color: #555;
font-weight: bold;
margin-top: 8px;
margin-bottom: 5px;
font-size: 12px;
text-transform: uppercase;
padding: 0;
}
h3 {
font-size: 14px;
margin: 10px 0px 0px 0px;
padding-bottom: 2px;
border-bottom: 1px solid #ccc;
color: black;
}
.related-peers-rs-peer {
margin: 5px 0px;
width: 100%;
td {
}
}
}
.card-related-peers {
table {
width: 100%;
}
.uptime {
text-align: right;
}
.peer-stats {
text-align: center;
cursor: default;
.routes-received {
color: green;
}
.routes-accepted {
color: green;
}
.routes-filtered {
color: orange;
}
.routes-exported {
}
}
}
.atooltip {
position: relative;
display: inline-block;
cursor: default;
}
.atooltip i {
position: absolute;
font-style: normal;
width:140px;
color: #ffffff;
background: #000000;
height: 30px;
line-height: 30px;
text-align: center;
visibility: hidden;
border-radius: 6px;
}
.atooltip i:after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -8px;
width: 0; height: 0;
border-top: 8px solid #000000;
border-right: 8px solid transparent;
border-left: 8px solid transparent;
}
.atooltip:hover i {
visibility: visible;
opacity: 0.8;
bottom: 30px;
left: 50%;
margin-left: -76px;
z-index: 999;
}

View File

@ -20,6 +20,10 @@ export const FETCH_ROUTES_NOT_EXPORTED_REQUEST = "@routes/FETCH_ROUTES_NOT_EXPOR
export const FETCH_ROUTES_NOT_EXPORTED_SUCCESS = "@routes/FETCH_ROUTES_NOT_EXPORTED_SUCCESS";
export const FETCH_ROUTES_NOT_EXPORTED_ERROR = "@routes/FETCH_ROUTES_NOT_EXPORTED_ERROR";
export const FETCH_RELATED_PEERS_REQUEST = "@related-peers/FETCH_REQUEST";
export const FETCH_RELATED_PEERS_SUCCESS = "@related-peers/FETCH_SUCCESS";
export const FETCH_RELATED_PEERS_ERROR = "@related-peers/FETCH_ERROR";
export const SET_FILTER_QUERY_VALUE = "@routes/SET_FILTER_QUERY_VALUE";
// Url helper
@ -136,3 +140,48 @@ export function setFilterQueryValue(value) {
}
}
export function fetchRelatedPeersRequest(asn) {
return {
type: FETCH_RELATED_PEERS_REQUEST,
payload: {
asn: asn,
}
}
}
export function fetchRelatedPeersSuccess(asn, result) {
return {
type: FETCH_RELATED_PEERS_SUCCESS,
payload: {
neighbors: result.neighbours, // TODO: fix inconsistency.
asn: asn,
}
}
}
export function fetchRelatedPeersError(asn, error) {
return {
type: FETCH_RELATED_PEERS_ERROR,
payload: {
error: error,
asn: asn,
}
}
}
export function fetchRelatedPeers(asn) {
return (dispatch) => {
dispatch(fetchRelatedPeersRequest(asn));
axios.get(`/api/v1/lookup/neighbors?asn=${asn}`)
.then(
({data}) => {
dispatch(fetchRelatedPeersSuccess(asn, data));
},
(error) => {
dispatch(fetchRelatedPeersError(asn, error));
// No global error handling if this fails.
});
}
}

View File

@ -22,7 +22,8 @@ import SearchInput from 'components/search-input'
import RoutesView from './view'
import QuickLinks from './quick-links'
import RelatedPeers from './related-peers'
import {RelatedPeersTabs,
RelatedPeersCard} from './related-peers'
import BgpAttributesModal
from './bgp-attributes-modal'
@ -37,7 +38,7 @@ import {mergeFilters} from 'components/filters/state'
import {makeLinkProps} from './urls'
// Actions
import {setFilterQueryValue}
import {setFilterQueryValue, fetchRelatedPeers}
from './actions'
import {loadRouteserverProtocol}
from 'components/routeservers/actions'
@ -122,6 +123,20 @@ class RoutesPage extends React.Component {
this.props.dispatch(
loadRouteserverProtocol(this.props.params.routeserverId)
);
if (this.props.neighbor) {
this.props.dispatch(
fetchRelatedPeers(this.props.neighbor.asn)
);
}
}
componentDidUpdate(prevProps) {
if (this.props.neighbor && this.props.neighbor != prevProps.neighbor) {
this.props.dispatch(
fetchRelatedPeers(this.props.neighbor.asn)
);
}
}
render() {
@ -133,7 +148,7 @@ class RoutesPage extends React.Component {
// We have to shift the layout a bit, to make room for
// the related peers tabs
let pageClass = "routeservers-page";
if (this.props.relatedPeers.length > 1) {
if (this.props.localRelatedPeers.length > 1) {
pageClass += " has-related-peers";
}
@ -161,9 +176,10 @@ class RoutesPage extends React.Component {
<div className="col-main col-lg-9 col-md-12">
<div className="card">
<RelatedPeers peers={this.props.relatedPeers}
protocolId={this.props.params.protocolId}
routeserverId={this.props.params.routeserverId} />
<RelatedPeersTabs
peers={this.props.localRelatedPeers}
protocolId={this.props.params.protocolId}
routeserverId={this.props.params.routeserverId} />
<SearchInput
value={this.props.filterValue}
placeholder={filterPlaceholder}
@ -206,6 +222,10 @@ class RoutesPage extends React.Component {
linkProps={this.props.linkProps}
filtersApplied={this.props.filtersApplied}
filtersAvailable={this.props.filtersAvailable} />
<RelatedPeersCard
neighbors={this.props.allRelatedPeers}
rsId={this.props.params.routeserverId}
protocolId={this.props.params.protocolId} />
</div>
</div>
</div>
@ -215,6 +235,8 @@ class RoutesPage extends React.Component {
}
export default connect(
(state, props) => {
const protocolId = props.params.protocolId;
@ -223,10 +245,10 @@ export default connect(
const neighbor = _.findWhere(neighbors, {id: protocolId});
// Find related peers. Peers belonging to the same AS.
let relatedPeers = [];
let localRelatedPeers = [];
if (neighbor) {
relatedPeers = _.where(neighbors, {asn: neighbor.asn,
state: "up"});
localRelatedPeers = _.where(
neighbors, {asn: neighbor.asn, state: "up"});
}
const received = {
@ -261,9 +283,10 @@ export default connect(
);
return({
neighbor: neighbor,
filterValue: state.routes.filterValue,
routes: {
[ROUTES_RECEIVED]: received,
[ROUTES_RECEIVED]: received,
[ROUTES_FILTERED]: filtered,
[ROUTES_NOT_EXPORTED]: notExported
},
@ -294,7 +317,8 @@ export default connect(
filtersApplied: filtersApplied,
},
relatedPeers: relatedPeers,
localRelatedPeers: localRelatedPeers,
allRelatedPeers: state.routes.allRelatedPeers,
// Loding indicator helper
receivedLoading: state.routes.receivedLoading,

View File

@ -9,7 +9,11 @@ import {FETCH_ROUTES_RECEIVED_REQUEST,
FETCH_ROUTES_NOT_EXPORTED_REQUEST,
FETCH_ROUTES_NOT_EXPORTED_SUCCESS,
FETCH_ROUTES_NOT_EXPORTED_ERROR} from './actions'
FETCH_ROUTES_NOT_EXPORTED_ERROR,
FETCH_RELATED_PEERS_REQUEST,
FETCH_RELATED_PEERS_SUCCESS,
FETCH_RELATED_PEERS_ERROR} from './actions'
import {ROUTES_RECEIVED,
ROUTES_FILTERED,
@ -63,6 +67,9 @@ const initialState = {
// Derived state from location
loadNotExported: false,
// Global related peers
allRelatedPeers: [],
filterValue: "",
filterQuery: "",
}
@ -167,6 +174,25 @@ function _handleFilterQueryValueChange(state, payload) {
}
// Related Peers
function _handleFetchRelatedPeersRequest(state, payload) {
return Object.assign({}, state, {
allRelatedPeers: [],
});
}
function _handleFetchRelatedPeersSuccess(state, payload) {
return Object.assign({}, state, {
allRelatedPeers: payload.neighbors,
});
}
function _handleFetchRelatedPeersError(state, payload) {
return Object.assign({}, state, {
allRelatedPeers: [],
});
}
export default function reducer(state=initialState, action) {
switch(action.type) {
@ -217,6 +243,14 @@ export default function reducer(state=initialState, action) {
return _handleFetchRoutesError(ROUTES_NOT_EXPORTED,
state,
action.payload);
// Related Peers
case FETCH_RELATED_PEERS_REQUEST:
return _handleFetchRelatedPeersRequest(state, action.payload);
case FETCH_RELATED_PEERS_SUCCESS:
return _handleFetchRelatedPeersSuccess(state, action.payload);
case FETCH_RELATED_PEERS_ERROR:
return _handleFetchRelatedPeersError(state, action.payload);
}
return state;

View File

@ -1,14 +1,18 @@
import React from 'react'
import {connect} from 'react-redux'
import {Link} from 'react-router'
import {makePeerLinkProps} from './urls'
import RelativeTimestamp
from 'components/datetime/relative-timestamp'
/*
* Render related peers as tabs
*/
export default function RelatedPeers(props) {
export function RelatedPeersTabs(props) {
if (props.peers.length < 2) {
return null; // Nothing to do here.
}
@ -31,3 +35,125 @@ export default function RelatedPeers(props) {
}
/*
* Display a link to a peer. If the peer state is up.
*/
function PeerLink(props) {
const neighbor = props.to;
if (!neighbor) {
return null;
}
const pid = neighbor.id;
const rid = neighbor.routeserver_id;
let peerUrl;
if (neighbor.state == "up") {
peerUrl = `/routeservers/${rid}/protocols/${pid}/routes`;
} else {
peerUrl = `/routeservers/${rid}#sessions-down`;
}
// Render link
return (
<a href={peerUrl}>{props.children}</a>
);
}
/*
* Show routes received, accepted, filtered, exported
*/
function RoutesStats(props) {
const {peer} = props;
if (peer.state != "up") {
return null; // Nothing to render
}
return (
<div className="related-peers-routes-stats">
<span className="atooltip routes-received">
{peer.routes_received}
<i>Routes Received</i>
</span> / <span className="atooltip routes-accepted">
{peer.routes_accepted}
<i>Routes Accepted</i>
</span> / <span className="atooltip routes-filtered">
{peer.routes_filtered}
<i>Routes Filtered</i>
</span> / <span className="atooltip routes-exported">
{peer.routes_exported}
<i>Routes Exported</i>
</span>
</div>
);
}
/*
* Render a card with related peers for the sidebar.
*
* This provides quick links to the same peer on other
* routeservers.
*/
function RelatedPeersCardView(props) {
let neighbors = props.neighbors;
if (!neighbors || neighbors.length < 2) {
return null; // nothing to render here.
}
// Exclude own neighbor and group peers by routeserver
let related = {};
for (let neighbor of neighbors) {
if (neighbor.routeserver_id == props.rsId &&
neighbor.id == props.protocolId) {
continue; // Skip current peer.
}
if (!related[neighbor.routeserver_id]) {
related[neighbor.routeserver_id] = [];
}
related[neighbor.routeserver_id].push(neighbor);
}
// Get routeserver info for routeserver id as key in object.
let relatedRs = [];
for (let rsId in related) {
relatedRs.push(props.routeservers[rsId]);
}
return (
<div className="card card-related-peers">
<h2 className="card-header">Related Neighbors</h2>
{relatedRs.map(rs => (
<div key={rs.id} className="related-peers-rs-group">
<h3>{rs.name}</h3>
<table className="related-peers-rs-peer">
<tbody>
{related[rs.id].map(peer => (
<tr key={peer.id}>
<td className="peer-address">
<PeerLink to={peer}>{peer.address}</PeerLink>
</td>
<td className="peer-stats">
<RoutesStats peer={peer} />
</td>
<td className="uptime">
{peer.state} for <RelativeTimestamp
value={peer.uptime}
suffix={true} />
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
);
}
export let RelatedPeersCard = connect(
(state) => ({
routeservers: state.routeservers.byId
})
)(RelatedPeersCardView);