From d5fd2ff1da8891422a50f49fa89127921cfed33b Mon Sep 17 00:00:00 2001 From: Gustavo Massaneiro Date: Wed, 19 Jan 2022 15:27:12 -0300 Subject: [PATCH] Service Map (#623) * debug builds and gcflags * update dockerfile for debug * service map routes and controller * service map graph structure * service map interface and new methods * adding service map edges from mizu entries * new service map count methods * implementing the status endpoint * ServiceMapResponse and ServiceMapEdge models * service map get endpoint logic * reset logic and endpoint * fixed service map get status * improvements to graph node structure * front-end implementation and service map buttons * new render endpoint to render the graph in real time * spinner sass * new ServiceMapModal component * testing react-force-graph-2d lib * Improvements to service map graph structure, added node id and updated edge source/destination type * Revert "testing react-force-graph-2d lib" This reverts commit 1153938386df5028c9b1265327601199242788ed. * testing react-graph-vis lib * updated to work with react-graph-vis lib * removed render endpoint * go mod tidy * serviceMap config flag * using the serviceMap config flag * passing mizu config to service map as a dependency * service map tests * Removed print functions * finished service map tests * new service property * service map controller tests * moved service map reset button to service map modal reset closes the modal * service map modal refresh button and logic * reset button resets data and refresh * service map modal close button * node size/edge size based on the count value edge color based on protocol * nodes and edges shadow * enabled physics to avoid node overlap, changed kafka protocol color to dark green * showing edges count values and fixed bidirectional edges overlap * go mod tidy * removed console.log * Using the destination node protocol instead of the source node protocol * Revert "debug builds and gcflags" Addressed by #624 and #626 This reverts commit 17ecaece3ed238d54b4e9aca3bb4a792a796ec9b. * Revert "update dockerfile for debug" Addressed by #635 This reverts commit 5dfc15b14066a39e932eae512752ef057a3dfe84. * using the entire tap Protocol struct instead of only the protocol name * using the backend protocol background color for node colors * fixed test, the node list order can change * re-factoring to get 100% coverage * using protocol colors just for edges * re-factored service map to use TCP Entry data. Node key is the entry ip-address instead of the name * fallback to ip-address when entry name is unresolved * re-factored front-end * adjustment to main div style * added support for multiple protocols for the same edge * using the item protocol instead of the extension variable * fixed controller tests * displaying service name and ip-address on graph nodes * fixed service map test, we cannot guarantee the slice order * auth middleware * created a new pkg for the service map * re-factoring * re-factored front-end * reverting the import order as previous * Aligning with other UI feature flags handling * we don't need to get the status anymore, we have window["isServiceMapEnabled"] * small adjustments * renamed from .tsx to .ts * button styles and minor improvements * moved service map modal from trafficPage to app component Co-authored-by: Igor Gov --- agent/go.mod | 1 + agent/main.go | 9 +- agent/pkg/api/main.go | 4 + .../pkg/controllers/service_map_controller.go | 36 ++ .../service_map_controller_test.go | 147 +++++++ agent/pkg/routes/service_map_routes.go | 19 + agent/pkg/servicemap/models.go | 32 ++ agent/pkg/servicemap/servicemap.go | 271 ++++++++++++ agent/pkg/servicemap/servicemap_test.go | 405 ++++++++++++++++++ ui/package-lock.json | 29 ++ ui/package.json | 1 + ui/public/index.html | 1 + ui/src/App.tsx | 21 +- ui/src/EntApp.tsx | 21 +- .../ServiceMapModal/ServiceMapModal.tsx | 216 ++++++++++ .../ServiceMapModal/ServiceMapOptions.ts | 83 ++++ ui/src/components/TrafficPage.tsx | 154 +++---- ui/src/components/style/Spinner.module.sass | 7 + ui/src/helpers/api.js | 15 + 19 files changed, 1384 insertions(+), 88 deletions(-) create mode 100644 agent/pkg/controllers/service_map_controller.go create mode 100644 agent/pkg/controllers/service_map_controller_test.go create mode 100644 agent/pkg/routes/service_map_routes.go create mode 100644 agent/pkg/servicemap/models.go create mode 100644 agent/pkg/servicemap/servicemap.go create mode 100644 agent/pkg/servicemap/servicemap_test.go create mode 100644 ui/src/components/ServiceMapModal/ServiceMapModal.tsx create mode 100644 ui/src/components/ServiceMapModal/ServiceMapOptions.ts create mode 100644 ui/src/components/style/Spinner.module.sass diff --git a/agent/go.mod b/agent/go.mod index 6bb42a3f7..39e3f779b 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -19,6 +19,7 @@ require ( github.com/orcaman/concurrent-map v0.0.0-20210106121528-16402b402231 github.com/ory/kratos-client-go v0.8.2-alpha.1 github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/stretchr/testify v1.7.0 github.com/up9inc/basenine/client/go v0.0.0-20220110083745-04fbc6c2068d github.com/up9inc/mizu/shared v0.0.0 github.com/up9inc/mizu/tap v0.0.0 diff --git a/agent/main.go b/agent/main.go index 33e7eaa27..126edfe76 100644 --- a/agent/main.go +++ b/agent/main.go @@ -13,6 +13,7 @@ import ( "mizuserver/pkg/models" "mizuserver/pkg/oas" "mizuserver/pkg/routes" + "mizuserver/pkg/servicemap" "mizuserver/pkg/up9" "mizuserver/pkg/utils" "net/http" @@ -153,6 +154,9 @@ func enableExpFeatureIfNeeded() { if config.Config.OAS { oas.GetOasGeneratorInstance().Start() } + if config.Config.ServiceMap { + servicemap.GetInstance().SetConfig(config.Config) + } } func configureBasenineServer(host string, port string) { @@ -254,10 +258,12 @@ func hostApi(socketHarOutputChannel chan<- *tapApi.OutputChannelItem) { routes.UserRoutes(app) routes.InstallRoutes(app) } - if config.Config.OAS { routes.OASRoutes(app) } + if config.Config.ServiceMap { + routes.ServiceMapRoutes(app) + } routes.QueryRoutes(app) routes.EntriesRoutes(app) @@ -286,6 +292,7 @@ func setUIFlags() error { replacedContent := strings.Replace(string(read), "__IS_STANDALONE__", strconv.FormatBool(config.Config.StandaloneMode), 1) replacedContent = strings.Replace(replacedContent, "__IS_OAS_ENABLED__", strconv.FormatBool(config.Config.OAS), 1) + replacedContent = strings.Replace(replacedContent, "__IS_SERVICE_MAP_ENABLED__", strconv.FormatBool(config.Config.ServiceMap), 1) err = ioutil.WriteFile(uiIndexPath, []byte(replacedContent), 0) if err != nil { diff --git a/agent/pkg/api/main.go b/agent/pkg/api/main.go index 12e972390..6d498c769 100644 --- a/agent/pkg/api/main.go +++ b/agent/pkg/api/main.go @@ -13,6 +13,8 @@ import ( "strings" "time" + "mizuserver/pkg/servicemap" + "github.com/google/martian/har" "github.com/up9inc/mizu/shared" "github.com/up9inc/mizu/shared/logger" @@ -146,6 +148,8 @@ func startReadingChannel(outputItems <-chan *tapApi.OutputChannelItem, extension panic(err) } connection.SendText(string(data)) + + servicemap.GetInstance().NewTCPEntry(mizuEntry.Source, mizuEntry.Destination, &item.Protocol) } } diff --git a/agent/pkg/controllers/service_map_controller.go b/agent/pkg/controllers/service_map_controller.go new file mode 100644 index 000000000..f469a24ef --- /dev/null +++ b/agent/pkg/controllers/service_map_controller.go @@ -0,0 +1,36 @@ +package controllers + +import ( + "mizuserver/pkg/servicemap" + "net/http" + + "github.com/gin-gonic/gin" +) + +type ServiceMapController struct { + service servicemap.ServiceMap +} + +func NewServiceMapController() *ServiceMapController { + return &ServiceMapController{ + service: servicemap.GetInstance(), + } +} + +func (s *ServiceMapController) Status(c *gin.Context) { + c.JSON(http.StatusOK, s.service.GetStatus()) +} + +func (s *ServiceMapController) Get(c *gin.Context) { + response := &servicemap.ServiceMapResponse{ + Status: s.service.GetStatus(), + Nodes: s.service.GetNodes(), + Edges: s.service.GetEdges(), + } + c.JSON(http.StatusOK, response) +} + +func (s *ServiceMapController) Reset(c *gin.Context) { + s.service.Reset() + s.Status(c) +} diff --git a/agent/pkg/controllers/service_map_controller_test.go b/agent/pkg/controllers/service_map_controller_test.go new file mode 100644 index 000000000..008463de7 --- /dev/null +++ b/agent/pkg/controllers/service_map_controller_test.go @@ -0,0 +1,147 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "mizuserver/pkg/servicemap" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/up9inc/mizu/shared" + tapApi "github.com/up9inc/mizu/tap/api" +) + +const ( + a = "aService" + b = "bService" + Ip = "127.0.0.1" + Port = "80" +) + +var ( + TCPEntryA = &tapApi.TCP{ + Name: a, + Port: Port, + IP: fmt.Sprintf("%s.%s", Ip, a), + } + TCPEntryB = &tapApi.TCP{ + Name: b, + Port: Port, + IP: fmt.Sprintf("%s.%s", Ip, b), + } +) + +var ProtocolHttp = &tapApi.Protocol{ + Name: "http", + LongName: "Hypertext Transfer Protocol -- HTTP/1.1", + Abbreviation: "HTTP", + Macro: "http", + Version: "1.1", + BackgroundColor: "#205cf5", + ForegroundColor: "#ffffff", + FontSize: 12, + ReferenceLink: "https://datatracker.ietf.org/doc/html/rfc2616", + Ports: []string{"80", "443", "8080"}, + Priority: 0, +} + +type ServiceMapControllerSuite struct { + suite.Suite + + c *ServiceMapController + w *httptest.ResponseRecorder + g *gin.Context +} + +func (s *ServiceMapControllerSuite) SetupTest() { + s.c = NewServiceMapController() + s.c.service.SetConfig(&shared.MizuAgentConfig{ + ServiceMap: true, + }) + s.c.service.NewTCPEntry(TCPEntryA, TCPEntryB, ProtocolHttp) + + s.w = httptest.NewRecorder() + s.g, _ = gin.CreateTestContext(s.w) +} + +func (s *ServiceMapControllerSuite) TestGetStatus() { + assert := s.Assert() + + s.c.Status(s.g) + assert.Equal(http.StatusOK, s.w.Code) + + var status servicemap.ServiceMapStatus + err := json.Unmarshal(s.w.Body.Bytes(), &status) + assert.NoError(err) + assert.Equal("enabled", status.Status) + assert.Equal(1, status.EntriesProcessedCount) + assert.Equal(2, status.NodeCount) + assert.Equal(1, status.EdgeCount) +} + +func (s *ServiceMapControllerSuite) TestGet() { + assert := s.Assert() + + s.c.Get(s.g) + assert.Equal(http.StatusOK, s.w.Code) + + var response servicemap.ServiceMapResponse + err := json.Unmarshal(s.w.Body.Bytes(), &response) + assert.NoError(err) + + // response status + assert.Equal("enabled", response.Status.Status) + assert.Equal(1, response.Status.EntriesProcessedCount) + assert.Equal(2, response.Status.NodeCount) + assert.Equal(1, response.Status.EdgeCount) + + // response nodes + aNode := servicemap.ServiceMapNode{ + Id: 1, + Name: TCPEntryA.IP, + Entry: TCPEntryA, + Count: 1, + } + bNode := servicemap.ServiceMapNode{ + Id: 2, + Name: TCPEntryB.IP, + Entry: TCPEntryB, + Count: 1, + } + assert.Contains(response.Nodes, aNode) + assert.Contains(response.Nodes, bNode) + assert.Len(response.Nodes, 2) + + // response edges + assert.Equal([]servicemap.ServiceMapEdge{ + { + Source: aNode, + Destination: bNode, + Protocol: ProtocolHttp, + Count: 1, + }, + }, response.Edges) +} + +func (s *ServiceMapControllerSuite) TestGetReset() { + assert := s.Assert() + + s.c.Reset(s.g) + assert.Equal(http.StatusOK, s.w.Code) + + var status servicemap.ServiceMapStatus + err := json.Unmarshal(s.w.Body.Bytes(), &status) + assert.NoError(err) + assert.Equal("enabled", status.Status) + assert.Equal(0, status.EntriesProcessedCount) + assert.Equal(0, status.NodeCount) + assert.Equal(0, status.EdgeCount) +} + +func TestServiceMapControllerSuite(t *testing.T) { + suite.Run(t, new(ServiceMapControllerSuite)) +} diff --git a/agent/pkg/routes/service_map_routes.go b/agent/pkg/routes/service_map_routes.go new file mode 100644 index 000000000..52b973963 --- /dev/null +++ b/agent/pkg/routes/service_map_routes.go @@ -0,0 +1,19 @@ +package routes + +import ( + "mizuserver/pkg/controllers" + "mizuserver/pkg/middlewares" + + "github.com/gin-gonic/gin" +) + +func ServiceMapRoutes(ginApp *gin.Engine) { + routeGroup := ginApp.Group("/servicemap") + routeGroup.Use(middlewares.RequiresAuth()) + + controller := controllers.NewServiceMapController() + + routeGroup.GET("/status", controller.Status) + routeGroup.GET("/get", controller.Get) + routeGroup.GET("/reset", controller.Reset) +} diff --git a/agent/pkg/servicemap/models.go b/agent/pkg/servicemap/models.go new file mode 100644 index 000000000..9550c70d9 --- /dev/null +++ b/agent/pkg/servicemap/models.go @@ -0,0 +1,32 @@ +package servicemap + +import ( + tapApi "github.com/up9inc/mizu/tap/api" +) + +type ServiceMapStatus struct { + Status string `json:"status"` + EntriesProcessedCount int `json:"entriesProcessedCount"` + NodeCount int `json:"nodeCount"` + EdgeCount int `json:"edgeCount"` +} + +type ServiceMapResponse struct { + Status ServiceMapStatus `json:"status"` + Nodes []ServiceMapNode `json:"nodes"` + Edges []ServiceMapEdge `json:"edges"` +} + +type ServiceMapNode struct { + Id int `json:"id"` + Name string `json:"name"` + Entry *tapApi.TCP `json:"entry"` + Count int `json:"count"` +} + +type ServiceMapEdge struct { + Source ServiceMapNode `json:"source"` + Destination ServiceMapNode `json:"destination"` + Count int `json:"count"` + Protocol *tapApi.Protocol `json:"protocol"` +} diff --git a/agent/pkg/servicemap/servicemap.go b/agent/pkg/servicemap/servicemap.go new file mode 100644 index 000000000..0738c0727 --- /dev/null +++ b/agent/pkg/servicemap/servicemap.go @@ -0,0 +1,271 @@ +package servicemap + +import ( + "sync" + + "github.com/up9inc/mizu/shared" + "github.com/up9inc/mizu/shared/logger" + tapApi "github.com/up9inc/mizu/tap/api" +) + +const ( + ServiceMapEnabled = "enabled" + ServiceMapDisabled = "disabled" + UnresolvedNodeName = "unresolved" +) + +var instance *serviceMap +var once sync.Once + +func GetInstance() ServiceMap { + once.Do(func() { + instance = newServiceMap() + logger.Log.Debug("Service Map Initialized") + }) + return instance +} + +type serviceMap struct { + config *shared.MizuAgentConfig + graph *graph + entriesProcessed int +} + +type ServiceMap interface { + SetConfig(config *shared.MizuAgentConfig) + IsEnabled() bool + NewTCPEntry(source *tapApi.TCP, destination *tapApi.TCP, protocol *tapApi.Protocol) + GetStatus() ServiceMapStatus + GetNodes() []ServiceMapNode + GetEdges() []ServiceMapEdge + GetEntriesProcessedCount() int + GetNodesCount() int + GetEdgesCount() int + Reset() +} + +func newServiceMap() *serviceMap { + return &serviceMap{ + config: nil, + entriesProcessed: 0, + graph: newDirectedGraph(), + } +} + +type key string + +type entryData struct { + key key + entry *tapApi.TCP +} + +type nodeData struct { + id int + entry *tapApi.TCP + count int +} + +type edgeProtocol struct { + protocol *tapApi.Protocol + count int +} + +type edgeData struct { + data map[key]*edgeProtocol +} + +type graph struct { + Nodes map[key]*nodeData + Edges map[key]map[key]*edgeData +} + +func newDirectedGraph() *graph { + return &graph{ + Nodes: make(map[key]*nodeData), + Edges: make(map[key]map[key]*edgeData), + } +} + +func newNodeData(id int, e *tapApi.TCP) *nodeData { + return &nodeData{ + id: id, + entry: e, + count: 1, + } +} + +func newEdgeData(p *tapApi.Protocol) *edgeData { + return &edgeData{ + data: map[key]*edgeProtocol{ + key(p.Name): { + protocol: p, + count: 1, + }, + }, + } +} + +func (s *serviceMap) nodeExists(k key) (*nodeData, bool) { + n, ok := s.graph.Nodes[k] + return n, ok +} + +func (s *serviceMap) addNode(k key, e *tapApi.TCP) (*nodeData, bool) { + nd, exists := s.nodeExists(k) + if !exists { + s.graph.Nodes[k] = newNodeData(len(s.graph.Nodes)+1, e) + return s.graph.Nodes[k], true + } + return nd, false +} + +func (s *serviceMap) addEdge(u, v *entryData, p *tapApi.Protocol) { + if n, ok := s.addNode(u.key, u.entry); !ok { + n.count++ + } + if n, ok := s.addNode(v.key, v.entry); !ok { + n.count++ + } + + if _, ok := s.graph.Edges[u.key]; !ok { + s.graph.Edges[u.key] = make(map[key]*edgeData) + } + + // new edge u -> v pair + // protocol is the same for u and v + if e, ok := s.graph.Edges[u.key][v.key]; ok { + // edge data already exists for u -> v pair + // we have a new protocol for this u -> v pair + + k := key(p.Name) + if pd, pOk := e.data[k]; pOk { + // protocol key already exists, just increment the count + pd.count++ + } else { + // new protocol key + e.data[k] = &edgeProtocol{ + protocol: p, + count: 1, + } + } + } else { + // new edge data for u -> v pair + s.graph.Edges[u.key][v.key] = newEdgeData(p) + } + + s.entriesProcessed++ +} + +func (s *serviceMap) SetConfig(config *shared.MizuAgentConfig) { + s.config = config +} + +func (s *serviceMap) IsEnabled() bool { + if s.config != nil && s.config.ServiceMap { + return true + } + return false +} + +func (s *serviceMap) NewTCPEntry(src *tapApi.TCP, dst *tapApi.TCP, p *tapApi.Protocol) { + if !s.IsEnabled() { + return + } + + srcEntry := &entryData{ + key: key(src.IP), + entry: src, + } + if len(srcEntry.entry.Name) == 0 { + srcEntry.entry.Name = UnresolvedNodeName + } + + dstEntry := &entryData{ + key: key(dst.IP), + entry: dst, + } + if len(dstEntry.entry.Name) == 0 { + dstEntry.entry.Name = UnresolvedNodeName + } + + s.addEdge(srcEntry, dstEntry, p) +} + +func (s *serviceMap) GetStatus() ServiceMapStatus { + status := ServiceMapDisabled + if s.IsEnabled() { + status = ServiceMapEnabled + } + + return ServiceMapStatus{ + Status: status, + EntriesProcessedCount: s.entriesProcessed, + NodeCount: s.GetNodesCount(), + EdgeCount: s.GetEdgesCount(), + } +} + +func (s *serviceMap) GetNodes() []ServiceMapNode { + var nodes []ServiceMapNode + for i, n := range s.graph.Nodes { + nodes = append(nodes, ServiceMapNode{ + Id: n.id, + Name: string(i), + Entry: n.entry, + Count: n.count, + }) + } + return nodes +} + +func (s *serviceMap) GetEdges() []ServiceMapEdge { + var edges []ServiceMapEdge + for u, m := range s.graph.Edges { + for v := range m { + for _, p := range s.graph.Edges[u][v].data { + edges = append(edges, ServiceMapEdge{ + Source: ServiceMapNode{ + Id: s.graph.Nodes[u].id, + Name: string(u), + Entry: s.graph.Nodes[u].entry, + Count: s.graph.Nodes[u].count, + }, + Destination: ServiceMapNode{ + Id: s.graph.Nodes[v].id, + Name: string(v), + Entry: s.graph.Nodes[v].entry, + Count: s.graph.Nodes[v].count, + }, + Count: p.count, + Protocol: p.protocol, + }) + } + } + } + return edges +} + +func (s *serviceMap) GetEntriesProcessedCount() int { + return s.entriesProcessed +} + +func (s *serviceMap) GetNodesCount() int { + return len(s.graph.Nodes) +} + +func (s *serviceMap) GetEdgesCount() int { + var count int + for u, m := range s.graph.Edges { + for v := range m { + for range s.graph.Edges[u][v].data { + count++ + } + } + } + return count +} + +func (s *serviceMap) Reset() { + s.entriesProcessed = 0 + s.graph = newDirectedGraph() +} diff --git a/agent/pkg/servicemap/servicemap_test.go b/agent/pkg/servicemap/servicemap_test.go new file mode 100644 index 000000000..0372fbde2 --- /dev/null +++ b/agent/pkg/servicemap/servicemap_test.go @@ -0,0 +1,405 @@ +package servicemap + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "github.com/up9inc/mizu/shared" + tapApi "github.com/up9inc/mizu/tap/api" +) + +const ( + a = "aService" + b = "bService" + c = "cService" + d = "dService" + Ip = "127.0.0.1" + Port = "80" +) + +var ( + TCPEntryA = &tapApi.TCP{ + Name: a, + Port: Port, + IP: fmt.Sprintf("%s.%s", Ip, a), + } + TCPEntryB = &tapApi.TCP{ + Name: b, + Port: Port, + IP: fmt.Sprintf("%s.%s", Ip, b), + } + TCPEntryC = &tapApi.TCP{ + Name: c, + Port: Port, + IP: fmt.Sprintf("%s.%s", Ip, c), + } + TCPEntryD = &tapApi.TCP{ + Name: d, + Port: Port, + IP: fmt.Sprintf("%s.%s", Ip, d), + } + TCPEntryUnresolved = &tapApi.TCP{ + Name: "", + Port: Port, + IP: Ip, + } + TCPEntryUnresolved2 = &tapApi.TCP{ + Name: "", + Port: Port, + IP: fmt.Sprintf("%s.%s", Ip, UnresolvedNodeName), + } + ProtocolHttp = &tapApi.Protocol{ + Name: "http", + LongName: "Hypertext Transfer Protocol -- HTTP/1.1", + Abbreviation: "HTTP", + Macro: "http", + Version: "1.1", + BackgroundColor: "#205cf5", + ForegroundColor: "#ffffff", + FontSize: 12, + ReferenceLink: "https://datatracker.ietf.org/doc/html/rfc2616", + Ports: []string{"80", "443", "8080"}, + Priority: 0, + } + ProtocolRedis = &tapApi.Protocol{ + Name: "redis", + LongName: "Redis Serialization Protocol", + Abbreviation: "REDIS", + Macro: "redis", + Version: "3.x", + BackgroundColor: "#a41e11", + ForegroundColor: "#ffffff", + FontSize: 11, + ReferenceLink: "https://redis.io/topics/protocol", + Ports: []string{"6379"}, + Priority: 3, + } +) + +type ServiceMapDisabledSuite struct { + suite.Suite + + instance ServiceMap +} + +type ServiceMapEnabledSuite struct { + suite.Suite + + instance ServiceMap +} + +func (s *ServiceMapDisabledSuite) SetupTest() { + s.instance = GetInstance() +} + +func (s *ServiceMapEnabledSuite) SetupTest() { + s.instance = GetInstance() + s.instance.SetConfig(&shared.MizuAgentConfig{ + ServiceMap: true, + }) +} + +func (s *ServiceMapDisabledSuite) TestServiceMapInstance() { + assert := s.Assert() + + assert.NotNil(s.instance) +} + +func (s *ServiceMapDisabledSuite) TestServiceMapSingletonInstance() { + assert := s.Assert() + + instance2 := GetInstance() + + assert.NotNil(s.instance) + assert.NotNil(instance2) + assert.Equal(s.instance, instance2) +} + +func (s *ServiceMapDisabledSuite) TestServiceMapIsEnabledShouldReturnFalseByDefault() { + assert := s.Assert() + + enabled := s.instance.IsEnabled() + + assert.False(enabled) +} + +func (s *ServiceMapDisabledSuite) TestGetStatusShouldReturnDisabledByDefault() { + assert := s.Assert() + + status := s.instance.GetStatus() + + assert.Equal("disabled", status.Status) + assert.Equal(0, status.EntriesProcessedCount) + assert.Equal(0, status.NodeCount) + assert.Equal(0, status.EdgeCount) +} + +func (s *ServiceMapDisabledSuite) TestNewTCPEntryShouldDoNothingWhenDisabled() { + assert := s.Assert() + + s.instance.NewTCPEntry(TCPEntryA, TCPEntryB, ProtocolHttp) + s.instance.NewTCPEntry(TCPEntryC, TCPEntryD, ProtocolHttp) + status := s.instance.GetStatus() + + assert.Equal("disabled", status.Status) + assert.Equal(0, status.EntriesProcessedCount) + assert.Equal(0, status.NodeCount) + assert.Equal(0, status.EdgeCount) +} + +// Enabled + +func (s *ServiceMapEnabledSuite) TestServiceMapIsEnabled() { + assert := s.Assert() + + enabled := s.instance.IsEnabled() + + assert.True(enabled) +} + +func (s *ServiceMapEnabledSuite) TestServiceMap() { + assert := s.Assert() + + // A -> B - HTTP + s.instance.NewTCPEntry(TCPEntryA, TCPEntryB, ProtocolHttp) + + nodes := s.instance.GetNodes() + edges := s.instance.GetEdges() + + // Counts for the first entry + assert.Equal(1, s.instance.GetEntriesProcessedCount()) + assert.Equal(2, s.instance.GetNodesCount()) + assert.Equal(2, len(nodes)) + assert.Equal(1, s.instance.GetEdgesCount()) + assert.Equal(1, len(edges)) + //http protocol + assert.Equal(1, edges[0].Count) + assert.Equal(ProtocolHttp.Name, edges[0].Protocol.Name) + + // same A -> B - HTTP, http protocol count should be 2, edges count should be 1 + s.instance.NewTCPEntry(TCPEntryA, TCPEntryB, ProtocolHttp) + + nodes = s.instance.GetNodes() + edges = s.instance.GetEdges() + + // Counts for a second entry + assert.Equal(2, s.instance.GetEntriesProcessedCount()) + assert.Equal(2, s.instance.GetNodesCount()) + assert.Equal(2, len(nodes)) + // edges count should still be 1, but http protocol count should be 2 + assert.Equal(1, s.instance.GetEdgesCount()) + assert.Equal(1, len(edges)) + // http protocol + assert.Equal(2, edges[0].Count) //http + assert.Equal(ProtocolHttp.Name, edges[0].Protocol.Name) + + // same A -> B - REDIS, http protocol count should be 2 and redis protocol count should 1, edges count should be 2 + s.instance.NewTCPEntry(TCPEntryA, TCPEntryB, ProtocolRedis) + + nodes = s.instance.GetNodes() + edges = s.instance.GetEdges() + + // Counts after second entry + assert.Equal(3, s.instance.GetEntriesProcessedCount()) + assert.Equal(2, s.instance.GetNodesCount()) + assert.Equal(2, len(nodes)) + // edges count should be 2, http protocol count should be 2 and redis protocol should be 1 + assert.Equal(2, s.instance.GetEdgesCount()) + assert.Equal(2, len(edges)) + // http and redis protocols + httpIndex := -1 + redisIndex := -1 + for i, e := range edges { + if e.Protocol.Name == ProtocolHttp.Name { + httpIndex = i + continue + } + if e.Protocol.Name == ProtocolRedis.Name { + redisIndex = i + } + } + assert.NotEqual(-1, httpIndex) + assert.NotEqual(-1, redisIndex) + // http protocol + assert.Equal(2, edges[httpIndex].Count) + assert.Equal(ProtocolHttp.Name, edges[httpIndex].Protocol.Name) + // redis protocol + assert.Equal(1, edges[redisIndex].Count) + assert.Equal(ProtocolRedis.Name, edges[redisIndex].Protocol.Name) + + // other entries + s.instance.NewTCPEntry(TCPEntryUnresolved, TCPEntryA, ProtocolHttp) + s.instance.NewTCPEntry(TCPEntryB, TCPEntryUnresolved2, ProtocolHttp) + s.instance.NewTCPEntry(TCPEntryC, TCPEntryD, ProtocolHttp) + s.instance.NewTCPEntry(TCPEntryA, TCPEntryC, ProtocolHttp) + + status := s.instance.GetStatus() + nodes = s.instance.GetNodes() + edges = s.instance.GetEdges() + expectedEntriesProcessedCount := 7 + expectedNodeCount := 6 + expectedEdgeCount := 6 + + // Counts after all entries + assert.Equal(expectedEntriesProcessedCount, s.instance.GetEntriesProcessedCount()) + assert.Equal(expectedNodeCount, s.instance.GetNodesCount()) + assert.Equal(expectedNodeCount, len(nodes)) + assert.Equal(expectedEdgeCount, s.instance.GetEdgesCount()) + assert.Equal(expectedEdgeCount, len(edges)) + + // Status + assert.Equal("enabled", status.Status) + assert.Equal(expectedEntriesProcessedCount, status.EntriesProcessedCount) + assert.Equal(expectedNodeCount, status.NodeCount) + assert.Equal(expectedEdgeCount, status.EdgeCount) + + // Nodes + aNode := -1 + bNode := -1 + cNode := -1 + dNode := -1 + unresolvedNode := -1 + unresolvedNode2 := -1 + var validateNode = func(node ServiceMapNode, entryName string, count int) int { + // id + assert.GreaterOrEqual(node.Id, 1) + assert.LessOrEqual(node.Id, expectedNodeCount) + + // entry + // node.Name is the key of the node, key = entry.IP + // entry.Name is the name of the service and could be unresolved + assert.Equal(node.Name, node.Entry.IP) + assert.Equal(Port, node.Entry.Port) + assert.Equal(entryName, node.Entry.Name) + + // count + assert.Equal(count, node.Count) + + return node.Id + } + + for _, v := range nodes { + if strings.HasSuffix(v.Name, a) { + aNode = validateNode(v, a, 5) + continue + } + if strings.HasSuffix(v.Name, b) { + bNode = validateNode(v, b, 4) + continue + } + if strings.HasSuffix(v.Name, c) { + cNode = validateNode(v, c, 2) + continue + } + if strings.HasSuffix(v.Name, d) { + dNode = validateNode(v, d, 1) + continue + } + if v.Name == Ip { + unresolvedNode = validateNode(v, UnresolvedNodeName, 1) + continue + } + if strings.HasSuffix(v.Name, UnresolvedNodeName) { + unresolvedNode2 = validateNode(v, UnresolvedNodeName, 1) + continue + } + } + + // Make sure we found all the nodes + nodeIds := [...]int{aNode, bNode, cNode, dNode, unresolvedNode, unresolvedNode2} + for _, v := range nodeIds { + assert.NotEqual(-1, v) + } + + // Edges + abEdge := -1 + uaEdge := -1 + buEdge := -1 + cdEdge := -1 + acEdge := -1 + var validateEdge = func(edge ServiceMapEdge, sourceEntryName string, destEntryName string, protocolName string, protocolCount int) { + // source + assert.Contains(nodeIds, edge.Source.Id) + assert.LessOrEqual(edge.Source.Id, expectedNodeCount) + assert.Equal(edge.Source.Name, edge.Source.Entry.IP) + assert.Equal(sourceEntryName, edge.Source.Entry.Name) + + // destination + assert.Contains(nodeIds, edge.Destination.Id) + assert.LessOrEqual(edge.Destination.Id, expectedNodeCount) + assert.Equal(edge.Destination.Name, edge.Destination.Entry.IP) + assert.Equal(destEntryName, edge.Destination.Entry.Name) + + // protocol + assert.Equal(protocolName, edge.Protocol.Name) + assert.Equal(protocolCount, edge.Count) + } + + for i, v := range edges { + if v.Source.Entry.Name == a && v.Destination.Entry.Name == b && v.Protocol.Name == "http" { + validateEdge(v, a, b, ProtocolHttp.Name, 2) + abEdge = i + continue + } + if v.Source.Entry.Name == a && v.Destination.Entry.Name == b && v.Protocol.Name == "redis" { + validateEdge(v, a, b, ProtocolRedis.Name, 1) + abEdge = i + continue + } + if v.Source.Entry.Name == UnresolvedNodeName && v.Destination.Entry.Name == a { + validateEdge(v, UnresolvedNodeName, a, ProtocolHttp.Name, 1) + uaEdge = i + continue + } + if v.Source.Entry.Name == b && v.Destination.Entry.Name == UnresolvedNodeName { + validateEdge(v, b, UnresolvedNodeName, ProtocolHttp.Name, 1) + buEdge = i + continue + } + if v.Source.Entry.Name == c && v.Destination.Entry.Name == d { + validateEdge(v, c, d, ProtocolHttp.Name, 1) + cdEdge = i + continue + } + if v.Source.Entry.Name == a && v.Destination.Entry.Name == c { + validateEdge(v, a, c, ProtocolHttp.Name, 1) + acEdge = i + continue + } + } + + // Make sure we found all the edges + for _, v := range [...]int{abEdge, uaEdge, buEdge, cdEdge, acEdge} { + assert.NotEqual(-1, v) + } + + // Reset + s.instance.Reset() + status = s.instance.GetStatus() + nodes = s.instance.GetNodes() + edges = s.instance.GetEdges() + + // Counts after reset + assert.Equal(0, s.instance.GetEntriesProcessedCount()) + assert.Equal(0, s.instance.GetNodesCount()) + assert.Equal(0, s.instance.GetEdgesCount()) + + // Status after reset + assert.Equal("enabled", status.Status) + assert.Equal(0, status.EntriesProcessedCount) + assert.Equal(0, status.NodeCount) + assert.Equal(0, status.EdgeCount) + + // Nodes after reset + assert.Equal([]ServiceMapNode(nil), nodes) + + // Edges after reset + assert.Equal([]ServiceMapEdge(nil), edges) +} + +func TestServiceMapSuite(t *testing.T) { + suite.Run(t, new(ServiceMapDisabledSuite)) + suite.Run(t, new(ServiceMapEnabledSuite)) +} diff --git a/ui/package-lock.json b/ui/package-lock.json index ec1438aea..18556e48d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -14479,6 +14479,25 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz", "integrity": "sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==" }, + "react-graph-vis": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/react-graph-vis/-/react-graph-vis-1.0.7.tgz", + "integrity": "sha512-FI35zlBMKU22JEvG1ukd1DDwW185y4YrDvHm6Bom9EGdA+UNMrZrIV/lyPIRWPcRkzbKaA1w1NvOYcRApD4KdQ==", + "requires": { + "lodash": "^4.17.15", + "prop-types": "^15.5.10", + "uuid": "^2.0.1", + "vis-data": "^7.1.2", + "vis-network": "^9.0.0" + }, + "dependencies": { + "uuid": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-2.0.3.tgz", + "integrity": "sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=" + } + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -17902,6 +17921,16 @@ "unist-util-stringify-position": "^3.0.0" } }, + "vis-data": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.2.tgz", + "integrity": "sha512-RPSegFxEcnp3HUEJSzhS2vBdbJ2PSsrYYuhRlpHp2frO/MfRtTYbIkkLZmPkA/Sg3pPfBlR235gcoKbtdm4mbw==" + }, + "vis-network": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.1.0.tgz", + "integrity": "sha512-rx96L144RJWcqOa6afjiFyxZKUerRRbT/YaNMpsusHdwzxrVTO2LlduR45PeJDEztrAf3AU5l2zmiG+1ydUZCw==" + }, "vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", diff --git a/ui/package.json b/ui/package.json index ddd6530fa..eda72c9b4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -30,6 +30,7 @@ "react": "^17.0.2", "react-copy-to-clipboard": "^5.0.3", "react-dom": "^17.0.2", + "react-graph-vis": "^1.0.7", "react-lowlight": "^3.0.0", "react-scripts": "4.0.3", "react-scrollable-feed-virtualized": "^1.4.9", diff --git a/ui/public/index.html b/ui/public/index.html index f8f972ceb..ca7eb0e75 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -30,6 +30,7 @@ // Injected from server window.isEnt = __IS_STANDALONE__ window.isOasEnabled = __IS_OAS_ENABLED__ + window.isServiceMapEnabled = __IS_SERVICE_MAP_ENABLED__ } catch (e) { } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 65b8a7aff..465128cda 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,6 +3,7 @@ import './App.sass'; import {TLSWarning} from "./components/TLSWarning/TLSWarning"; import {Header} from "./components/Header/Header"; import {TrafficPage} from "./components/TrafficPage"; +import { ServiceMapModal } from './components/ServiceMapModal/ServiceMapModal'; const App = () => { @@ -10,6 +11,7 @@ const App = () => { const [showTLSWarning, setShowTLSWarning] = useState(false); const [userDismissedTLSWarning, setUserDismissedTLSWarning] = useState(false); const [addressesWithTLS, setAddressesWithTLS] = useState(new Set()); + const [openServiceMapModal, setOpenServiceMapModal] = useState(false); const onTLSDetected = (destAddress: string) => { addressesWithTLS.add(destAddress); @@ -22,14 +24,19 @@ const App = () => { return (
-
- +
+ + setShowTLSWarning={setShowTLSWarning} + addressesWithTLS={addressesWithTLS} + setAddressesWithTLS={setAddressesWithTLS} + userDismissedTLSWarning={userDismissedTLSWarning} + setUserDismissedTLSWarning={setUserDismissedTLSWarning} /> + {window["isServiceMapEnabled"] && setOpenServiceMapModal(true)} + onClose={() => setOpenServiceMapModal(false)} + />}
); } diff --git a/ui/src/EntApp.tsx b/ui/src/EntApp.tsx index 34bebdf32..9a22b60c6 100644 --- a/ui/src/EntApp.tsx +++ b/ui/src/EntApp.tsx @@ -11,6 +11,7 @@ import LoadingOverlay from "./components/LoadingOverlay"; import AuthPageBase from './components/AuthPageBase'; import entPageAtom, {Page} from "./recoil/entPage"; import {useRecoilState} from "recoil"; +import { ServiceMapModal } from './components/ServiceMapModal/ServiceMapModal'; const api = Api.getInstance(); @@ -22,6 +23,7 @@ const EntApp = () => { const [addressesWithTLS, setAddressesWithTLS] = useState(new Set()); const [entPage, setEntPage] = useRecoilState(entPageAtom); const [isFirstLogin, setIsFirstLogin] = useState(false); + const [openServiceMapModal, setOpenServiceMapModal] = useState(false); const determinePage = useCallback(async () => { // TODO: move to state management try { @@ -59,7 +61,7 @@ const EntApp = () => { switch (entPage) { // TODO: move to state management / proper routing case Page.Traffic: - pageComponent = ; + pageComponent = ; break; case Page.Setup: pageComponent = setIsFirstLogin(true)}/>; @@ -77,14 +79,19 @@ const EntApp = () => { return (
- {entPage === Page.Traffic && } + {entPage === Page.Traffic && } {pageComponent} {entPage === Page.Traffic && } + setShowTLSWarning={setShowTLSWarning} + addressesWithTLS={addressesWithTLS} + setAddressesWithTLS={setAddressesWithTLS} + userDismissedTLSWarning={userDismissedTLSWarning} + setUserDismissedTLSWarning={setUserDismissedTLSWarning} />} + {entPage === Page.Traffic && window["isServiceMapEnabled"] && setOpenServiceMapModal(true)} + onClose={() => setOpenServiceMapModal(false)} + />}
); } diff --git a/ui/src/components/ServiceMapModal/ServiceMapModal.tsx b/ui/src/components/ServiceMapModal/ServiceMapModal.tsx new file mode 100644 index 000000000..c801141e1 --- /dev/null +++ b/ui/src/components/ServiceMapModal/ServiceMapModal.tsx @@ -0,0 +1,216 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Box, Fade, Modal, Backdrop, Button } from "@material-ui/core"; +import { toast } from "react-toastify"; +import Api from "../../helpers/api"; +import spinnerStyle from '../style/Spinner.module.sass'; +import spinnerImg from '../assets/spinner.svg'; +import Graph from "react-graph-vis"; +import debounce from 'lodash/debounce'; +import ServiceMapOptions from './ServiceMapOptions' +import { useCommonStyles } from "../../helpers/commonStyle"; + +interface GraphData { + nodes: Node[]; + edges: Edge[]; +} + +interface Node { + id: number; + value: number; + label: string; + title?: string; + color?: object; +} + +interface Edge { + from: number; + to: number; + value: number; + label: string; + title?: string; + color?: object; +} + +interface ServiceMapNode { + id: number; + name: string; + entry: Entry; + count: number; +} + +interface ServiceMapEdge { + source: ServiceMapNode; + destination: ServiceMapNode; + count: number; + protocol: Protocol; +} + +interface ServiceMapGraph { + nodes: ServiceMapNode[]; + edges: ServiceMapEdge[]; +} + +interface Entry { + ip: string; + port: string; + name: string; +} + +interface Protocol { + name: string; + abbr: string; + macro: string; + version: string; + backgroundColor: string; + foregroundColor: string; + fontSize: number; + referenceLink: string; + ports: string[]; + priority: number; +} + +interface ServiceMapModalProps { + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +} + +const modalStyle = { + position: 'absolute', + top: '10%', + left: '50%', + transform: 'translate(-50%, 0%)', + width: '80vw', + height: '80vh', + bgcolor: 'background.paper', + borderRadius: '5px', + boxShadow: 24, + p: 4, + color: '#000', +}; + +const api = Api.getInstance(); + +export const ServiceMapModal: React.FC = ({ isOpen, onOpen, onClose }) => { + const commonClasses = useCommonStyles(); + const [isLoading, setIsLoading] = useState(true); + const [graphData, setGraphData] = useState({ nodes: [], edges: [] }); + + const getServiceMapData = useCallback(async () => { + try { + setIsLoading(true) + + const serviceMapData: ServiceMapGraph = await api.serviceMapData() + const newGraphData: GraphData = { nodes: [], edges: [] } + + if (serviceMapData.nodes) { + newGraphData.nodes = serviceMapData.nodes.map(node => { + return { + id: node.id, + value: node.count, + label: (node.entry.name === "unresolved") ? node.name : `${node.entry.name} (${node.name})`, + title: "Count: " + node.name, + } + }) + } + + if (serviceMapData.edges) { + newGraphData.edges = serviceMapData.edges.map(edge => { + return { + from: edge.source.id, + to: edge.destination.id, + value: edge.count, + label: edge.count.toString(), + color: { + color: edge.protocol.backgroundColor, + highlight: edge.protocol.backgroundColor + }, + } + }) + } + + setGraphData(newGraphData) + + } catch (ex) { + toast.error("An error occurred while loading Mizu Service Map, see console for mode details"); + console.error(ex); + } finally { + setIsLoading(false) + } + // eslint-disable-next-line + }, [isOpen]) + + useEffect(() => { + getServiceMapData() + }, [getServiceMapData]) + + const resetServiceMap = debounce(async () => { + try { + const serviceMapResetResponse = await api.serviceMapReset(); + if (serviceMapResetResponse["status"] === "enabled") { + refreshServiceMap() + } + + } catch (ex) { + toast.error("An error occurred while resetting Mizu Service Map, see console for mode details"); + console.error(ex); + } + }, 500); + + const refreshServiceMap = debounce(() => { + getServiceMapData(); + }, 500); + + return ( + + + + {isLoading &&
+ spinner +
} + {!isLoading &&
+ + + + +
} +
+
+
+ ); + +} \ No newline at end of file diff --git a/ui/src/components/ServiceMapModal/ServiceMapOptions.ts b/ui/src/components/ServiceMapModal/ServiceMapOptions.ts new file mode 100644 index 000000000..a63339ad7 --- /dev/null +++ b/ui/src/components/ServiceMapModal/ServiceMapOptions.ts @@ -0,0 +1,83 @@ +const ServiceMapOptions = { + physics: { + enabled: true, + solver: 'barnesHut', + barnesHut: { + theta: 0.5, + gravitationalConstant: -2000, + centralGravity: 0.3, + springLength: 180, + springConstant: 0.04, + damping: 0.09, + avoidOverlap: 1 + }, + }, + layout: { + hierarchical: false, + randomSeed: 1 // always on node 1 + }, + nodes: { + shape: 'dot', + chosen: true, + color: { + background: '#27AE60', + border: '#000000', + highlight: { + background: '#27AE60', + border: '#000000', + }, + }, + font: { + color: '#343434', + size: 14, // px + face: 'arial', + background: 'none', + strokeWidth: 0, // px + strokeColor: '#ffffff', + align: 'center', + multi: false, + }, + borderWidth: 1.5, + borderWidthSelected: 2.5, + labelHighlightBold: true, + opacity: 1, + shadow: true, + }, + edges: { + chosen: true, + dashes: false, + arrowStrikethrough: false, + arrows: { + to: { + enabled: true, + }, + middle: { + enabled: false, + }, + from: { + enabled: false, + } + }, + smooth: { + enabled: true, + type: 'dynamic', + roundness: 1.0 + }, + font: { + color: '#343434', + size: 12, // px + face: 'arial', + background: 'none', + strokeWidth: 2, // px + strokeColor: '#ffffff', + align: 'horizontal', + multi: false, + }, + labelHighlightBold: true, + selectionWidth: 1, + shadow: true, + }, + autoResize: true, +}; + +export default ServiceMapOptions \ No newline at end of file diff --git a/ui/src/components/TrafficPage.tsx b/ui/src/components/TrafficPage.tsx index 8288597b5..9a0ddb529 100644 --- a/ui/src/components/TrafficPage.tsx +++ b/ui/src/components/TrafficPage.tsx @@ -19,6 +19,7 @@ import focusedEntryIdAtom from "../recoil/focusedEntryId"; import websocketConnectionAtom, {WsConnectionStatus} from "../recoil/wsConnection"; import queryAtom from "../recoil/query"; import OasModal from "./OasModal/OasModal"; +import {useCommonStyles} from "../helpers/commonStyle" const useLayoutStyles = makeStyles(() => ({ details: { @@ -43,11 +44,13 @@ const useLayoutStyles = makeStyles(() => ({ interface TrafficPageProps { setAnalyzeStatus?: (status: any) => void; onTLSDetected: (destAddress: string) => void; + setOpenServiceMapModal?: (open: boolean) => void; } const api = Api.getInstance(); -export const TrafficPage: React.FC = ({setAnalyzeStatus,onTLSDetected}) => { +export const TrafficPage: React.FC = ({setAnalyzeStatus,onTLSDetected, setOpenServiceMapModal}) => { + const commonClasses = useCommonStyles(); const classes = useLayoutStyles(); const [tappingStatus, setTappingStatus] = useRecoilState(tappingStatusAtom); const [entries, setEntries] = useRecoilState(entriesAtom); @@ -239,79 +242,84 @@ export const TrafficPage: React.FC = ({setAnalyzeStatus,onTLSD } } - return ( -
-
-
- pause - play -
- {getConnectionTitle()} -
-
-
-
-
- {window["isOasEnabled"] &&
- -
} + const openServiceMapModalDebounce = debounce(() => { + setOpenServiceMapModal(true) + }, 500); + + return ( +
+
+
+ pause + play +
+ {getConnectionTitle()} +
+
- {window["isOasEnabled"] && } - {
-
- -
- -
-
-
- {focusedEntryId && } -
-
} - {tappingStatus && !openOasModal && } +
+
+ {window["isOasEnabled"] && } + {window["isServiceMapEnabled"] && } +
+
+ {window["isOasEnabled"] && } + {
+
+ +
+ +
+
+
+ {focusedEntryId && } +
+
} + {tappingStatus && !openOasModal && } +
); }; diff --git a/ui/src/components/style/Spinner.module.sass b/ui/src/components/style/Spinner.module.sass new file mode 100644 index 000000000..3f689dc41 --- /dev/null +++ b/ui/src/components/style/Spinner.module.sass @@ -0,0 +1,7 @@ +@import "../../variables.module" + +.spinnerContainer + display: flex + justify-content: center + margin-bottom: 10px + \ No newline at end of file diff --git a/ui/src/helpers/api.js b/ui/src/helpers/api.js index 0df8978ab..ae85c78a6 100644 --- a/ui/src/helpers/api.js +++ b/ui/src/helpers/api.js @@ -28,6 +28,21 @@ export default class Api { this.source = null; } + serviceMapStatus = async () => { + const response = await this.client.get("/servicemap/status"); + return response.data; + } + + serviceMapData = async () => { + const response = await this.client.get(`/servicemap/get`); + return response.data; + } + + serviceMapReset = async () => { + const response = await this.client.get(`/servicemap/reset`); + return response.data; + } + tapStatus = async () => { const response = await this.client.get("/status/tap"); return response.data;