From d9dedc3715919a081c06f0444ee2d542882e9561 Mon Sep 17 00:00:00 2001 From: Anthony Law Date: Sat, 23 Aug 2025 01:59:56 +0800 Subject: [PATCH 1/3] [explorer] task: migrate node pages to node watch services Problem: migrate explorer to node watch services Solution: Added node watch services to query the node watch endpoint Rename nodePublicKey to mainPublicKey Rename hostDetail to geoLocation Removed Peer Status card table, because it is no longer needed. Updated node services and unit test --- __tests__/TestHelper.js | 64 +-- __tests__/components/NodesMap.spec.js | 10 +- __tests__/infrastructure/NodeService.spec.js | 382 ++++++++---------- .../infrastructure/NodeWatchService.spec.js | 86 ++++ __tests__/store/api.spec.js | 2 +- src/components/NodesMap.vue | 6 +- src/components/tables/TableView.vue | 3 +- src/config/default.json | 3 +- src/config/i18n/en-us.json | 2 +- src/config/i18n/es.json | 2 +- src/config/i18n/ja.json | 2 +- src/config/i18n/ko.json | 2 +- src/config/i18n/pt.json | 2 +- src/config/i18n/ru.json | 2 +- src/config/i18n/ua.json | 2 +- src/config/i18n/zh.json | 2 +- src/config/key-redirects.json | 4 +- src/config/pages/node-detail.json | 30 +- src/config/pages/node-list.json | 4 +- src/infrastructure/NodeService.js | 253 +++++------- src/infrastructure/NodeWatchService.js | 33 ++ src/infrastructure/index.js | 2 + src/store/node.js | 3 +- 23 files changed, 443 insertions(+), 458 deletions(-) create mode 100644 __tests__/infrastructure/NodeWatchService.spec.js create mode 100644 src/infrastructure/NodeWatchService.js diff --git a/__tests__/TestHelper.js b/__tests__/TestHelper.js index 5861dd001..6adcce7b8 100644 --- a/__tests__/TestHelper.js +++ b/__tests__/TestHelper.js @@ -365,52 +365,26 @@ const TestHelper = { innerTransactions }; }, - generateNodePeerStatus: isAvailable => { - return { - isAvailable, - lastStatusCheck: 1676809816662 - }; - }, - generateNodeApiStatus: isAvailable => { - return { - isAvailable, - nodePublicKey: '4DA6FB57FA168EEBBCB68DA4DDC8DA7BCF41EC93FB22A33DF510DB0F2670F623', - chainHeight: 2027193, - finalization: { - height: 2031992, - epoch: 1413, - point: 7, - hash: '6B687D9B689611C90A1094A7430E78914F22A2570C80D3E42D520EB08091A973' - }, - nodeStatus: { - apiNode: 'up', - db: 'up' - }, - restVersion: '2.4.2', - restGatewayUrl: 'localhost.com', - isHttpsEnabled: true - }; - }, nodeCommonField: { - version: 16777989, - publicKey: '016DC1622EE42EF9E4D215FA1112E89040DD7AED83007283725CE9BA550272F5', - networkGenerationHashSeed: '57F7DA205008026C776CB6AED843393F04CD458E0AA2D9F1D5F31A402072B2D6', - port: 7900, - networkIdentifier: 104, - host: 'node.com', - friendlyName: 'node', - lastAvailable: '2023-02-19T12:36:04.524Z', - hostDetail: {}, - location: '', - ip: '127.0.0.1', - organization: '', - as: '', - continent: '', - country: '', - region: '', - city: '', - district: '', - zip: '' + version: '1.0.3.5', + mainPublicKey: '016DC1622EE42EF9E4D215FA1112E89040DD7AED83007283725CE9BA550272F5', + endpoint: 'http://node.com:3000', + finalizedEpoch: 50, + finalizedHash: 'finalized hash', + finalizedHeight: 100, + finalizedPoint: 1, + geoLocation: null, + height: 120, + name: 'node' + }, + geoLocationCommonField: { + city: 'ABC City', + continent: 'ABC', + country: 'ABC', + isp: 'ABC Online', + lat: 10.000, + lon: 20.000, + region: 'SN' } }; diff --git a/__tests__/components/NodesMap.spec.js b/__tests__/components/NodesMap.spec.js index ef90327c6..087cad49c 100644 --- a/__tests__/components/NodesMap.spec.js +++ b/__tests__/components/NodesMap.spec.js @@ -10,7 +10,7 @@ jest.mock('../../src/styles/img/connector_blue_light.png', () => 'blue-light.png jest.mock('../../src/styles/img/connector_green.png', () => 'green.png'); jest.mock('../../src/styles/img/connector_green_light.png', () => 'green-light.png'); -const setupStoreMount = (role, apiStatus) => { +const setupStoreMount = (role, isApiNode) => { const nodeModule = { namespaced: true }; @@ -36,9 +36,7 @@ const setupStoreMount = (role, apiStatus) => { const propsData = { nodes: [{ rolesRaw: role, - apiStatus: { - isAvailable: apiStatus - }, + isApiNode, coordinates: { latitude: 1, longitude: 2 @@ -58,9 +56,9 @@ localVue.use(Vuex); describe('NodesMap', () => { describe('addMarkers', () => { - const assertMarkerIcon = (role, apiStatus, expectedIcon) => { + const assertMarkerIcon = (role, isApiNode, expectedIcon) => { // Arrange: - const wrapper = setupStoreMount(role, apiStatus); + const wrapper = setupStoreMount(role, isApiNode); // Act: wrapper.vm.addMarkers(); diff --git a/__tests__/infrastructure/NodeService.spec.js b/__tests__/infrastructure/NodeService.spec.js index 8e051ec3d..a1d465dd7 100644 --- a/__tests__/infrastructure/NodeService.spec.js +++ b/__tests__/infrastructure/NodeService.spec.js @@ -1,80 +1,83 @@ -import { NodeService } from '../../src/infrastructure'; -import http from '../../src/infrastructure/http'; +import { NodeService, NodeWatchService } from '../../src/infrastructure'; import TestHelper from '../TestHelper'; describe('Node Service', () => { // Arrange: const { - generateNodePeerStatus, - generateNodeApiStatus, - nodeCommonField + nodeCommonField, + geoLocationCommonField } = TestHelper; - const statisticServiceNodeResponse = [ + const nodeWatchServiceNodeResponse = [ { roles: 1, - peerStatus: generateNodePeerStatus(true), + restVersion: null, + isHealthy: null, + isSslEnabled: null, ...nodeCommonField }, { - roles: 2, - apiStatus: generateNodeApiStatus(false), + roles: 1, // Peer node (light) + restVersion: '2.4.4', + isHealthy: null, + isSslEnabled: true, ...nodeCommonField }, { roles: 3, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(false), + restVersion: '2.4.4', + isHealthy: true, + isSslEnabled: true, ...nodeCommonField }, { - roles: 3, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(true), - ...nodeCommonField - }, - { - roles: 3, - peerStatus: generateNodePeerStatus(false), - apiStatus: generateNodeApiStatus(false), - ...nodeCommonField - }, - { - roles: 5, - peerStatus: generateNodePeerStatus(true), + roles: 5, // Peer Voting node (light) + restVersion: '2.4.4', + isHealthy: null, + isSslEnabled: true, ...nodeCommonField }, { roles: 5, - peerStatus: generateNodePeerStatus(false), + restVersion: null, + isHealthy: null, + isSslEnabled: null, ...nodeCommonField }, { roles: 7, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(true), + restVersion: '2.4.4', + isHealthy: true, + isSslEnabled: true, ...nodeCommonField } ]; const nodeFormattedCommonField = { - network: 'MAINNET', - address: 'NDY2CXBR6SK3G7UWVXZT6YQTVJKHKFMPU74ZOYY', - nodePublicKey: + network: 'TESTNET', + networkIdentifier: 152, + address: 'TDY2CXBR6SK3G7UWVXZT6YQTVJKHKFMPU6UDZ6Q', + mainPublicKey: '016DC1622EE42EF9E4D215FA1112E89040DD7AED83007283725CE9BA550272F5', - version: '1.0.3.5' + version: '1.0.3.5', + friendlyName: 'node', + finalizedEpoch: 50, + finalizedHash: 'finalized hash', + finalizedHeight: 100, + finalizedPoint: 1, + geoLocation: null, + height: 120, + host: 'node.com', + port: '3000', + networkGenerationHashSeed: '57F7DA205008026C776CB6AED843393F04CD458E0AA2D9F1D5F31A402072B2D6' }; - const runStatisticServiceFailResponseTests = (statisticServiceMethod, NodeServiceMethod) => { - it('throws error when statistic services fail response', async () => { + const runNodeWatchFailResponseTests = (nodeWatchMethod, NodeServiceMethod) => { + it('throws error when node watch fail response', async () => { // Arrange: - const error = new Error(`Statistics service ${statisticServiceMethod} error`); + const error = new Error(`node watch ${nodeWatchMethod} error`); - http.statisticServiceRestClient = jest.fn().mockImplementation(() => { - return { - [statisticServiceMethod]: jest.fn().mockRejectedValue(error) - }; - }); + jest.spyOn(NodeWatchService, nodeWatchMethod).mockRejectedValue(error); // Act + Assert: await expect(NodeService[NodeServiceMethod]()).rejects.toThrow(error); @@ -82,13 +85,9 @@ describe('Node Service', () => { }; describe('getAvailableNodes', () => { - it('returns available node from statistic services', async () => { + it('returns available node from node watch services', async () => { // Arrange: - http.statisticServiceRestClient = jest.fn().mockImplementation(() => { - return { - getNodes: jest.fn().mockResolvedValue(statisticServiceNodeResponse) - }; - }); + jest.spyOn(NodeWatchService, 'getNodes').mockResolvedValue(nodeWatchServiceNodeResponse); // Act: const result = await NodeService.getAvailableNodes(); @@ -96,123 +95,80 @@ describe('Node Service', () => { // Assert: expect(result).toEqual([ { - ...nodeCommonField, ...nodeFormattedCommonField, + restVersion: null, + isHealthy: null, + isHttpsEnabled: null, apiEndpoint: 'N/A', roles: 'Peer node', - rolesRaw: 1, - peerStatus: generateNodePeerStatus(true) + rolesRaw: 1 }, { - ...nodeCommonField, ...nodeFormattedCommonField, - apiEndpoint: 'localhost.com', - roles: 'Peer Api node', - rolesRaw: 3, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(false) + restVersion: '2.4.4', + isHealthy: null, + isHttpsEnabled: true, + apiEndpoint: 'http://node.com:3000', + roles: 'Peer node (light)', + rolesRaw: 1 }, { - ...nodeCommonField, ...nodeFormattedCommonField, - apiEndpoint: 'localhost.com', + restVersion: '2.4.4', + isHealthy: true, + isHttpsEnabled: true, + apiEndpoint: 'http://node.com:3000', roles: 'Peer Api node', - rolesRaw: 3, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(true) + rolesRaw: 3 + }, + { + ...nodeFormattedCommonField, + restVersion: '2.4.4', + isHealthy: null, + isHttpsEnabled: true, + apiEndpoint: 'http://node.com:3000', + roles: 'Peer Voting node (light)', + rolesRaw: 5 }, { - ...nodeCommonField, ...nodeFormattedCommonField, + restVersion: null, + isHealthy: null, + isHttpsEnabled: null, apiEndpoint: 'N/A', roles: 'Peer Voting node', - rolesRaw: 5, - peerStatus: generateNodePeerStatus(true) + rolesRaw: 5 }, { - ...nodeCommonField, ...nodeFormattedCommonField, - apiEndpoint: 'localhost.com', + restVersion: '2.4.4', + isHealthy: true, + isHttpsEnabled: true, + apiEndpoint: 'http://node.com:3000', roles: 'Peer Api Voting node', - rolesRaw: 7, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(true) + rolesRaw: 7 } ]); }); - it('returns available light node from statistic services', async () => { - // Arrange: - const createExpectedNode = (rolesRaw, roleName) => ({ - ...nodeCommonField, - ...nodeFormattedCommonField, - apiEndpoint: 'N/A', - roles: roleName, - rolesRaw, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(true) - }); - - const statisticServiceLightNodeResponse = [ - { - roles: 1, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(true), - ...nodeCommonField - }, - { - roles: 4, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(true), - ...nodeCommonField - }, - { - roles: 5, - peerStatus: generateNodePeerStatus(true), - apiStatus: generateNodeApiStatus(true), - ...nodeCommonField - } - ]; - - http.statisticServiceRestClient = jest.fn().mockImplementation(() => { - return { - getNodes: jest.fn().mockResolvedValue(statisticServiceLightNodeResponse) - }; - }); - - // Act: - const result = await NodeService.getAvailableNodes(); - - // Assert: - expect(result).toEqual([ - createExpectedNode(1, 'Peer node (light)'), - createExpectedNode(4, 'Voting node (light)'), - createExpectedNode(5, 'Peer Voting node (light)') - ]); - }); - - runStatisticServiceFailResponseTests('getNodes', 'getAvailableNodes'); + runNodeWatchFailResponseTests('getNodes', 'getAvailableNodes'); }); describe('getNodeStats', () => { it('return nodes count with 7 types of roles', async () => { // Arrange: - http.statisticServiceRestClient = jest.fn().mockImplementation(() => { - return { - getNodes: jest.fn().mockResolvedValue(statisticServiceNodeResponse) - }; - }); + jest.spyOn(NodeWatchService, 'getNodes').mockResolvedValue(nodeWatchServiceNodeResponse); // Act: const nodeStats = await NodeService.getNodeStats(); // Assert: expect(nodeStats).toEqual({ - 1: 1, + 1: 2, 2: 0, - 3: 2, + 3: 1, 4: 0, - 5: 1, + 5: 2, 6: 0, 7: 1 }); @@ -223,127 +179,117 @@ describe('Node Service', () => { // Arrange: Date.now = jest.fn(() => new Date('2023-02-21')); - const expectedPeerStatus = { - connectionStatus: true, - lastStatusCheck: '2023-02-19 12:30:16' - }; - - const expectedAPIStatus = { - apiNodeStatus: true, - connectionStatus: false, - databaseStatus: true, - isHttpsEnabled: true, - lastStatusCheck: '2023-02-21 00:00:00', - restVersion: '2.4.2' - }; - const expectedChainInfoStatus = { - height: 2027193, - finalizedHeight: 2031992, - finalizationEpoch: 1413, - finalizationPoint: 7, - finalizedHash: '6B687D9B689611C90A1094A7430E78914F22A2570C80D3E42D520EB08091A973', - lastStatusCheck: '2023-02-21 00:00:00' + height: 120, + finalizedHeight: 100, + finalizationEpoch: 50, + finalizationPoint: 1, + finalizedHash: 'finalized hash' }; const assertNodeStatus = async (node, expectedResult) => { // Arrange: - http.statisticServiceRestClient = jest.fn().mockImplementation(() => { - return { - getNode: jest.fn().mockResolvedValue(node) - }; - }); + jest.spyOn(NodeWatchService, 'getNodeByMainPublicKey').mockResolvedValue(node); // Act: - const { apiStatus, chainInfo, peerStatus, mapInfo } = - await NodeService.getNodeInfo(node.publicKey); + const { apiStatus, chainInfo, mapInfo } = + await NodeService.getNodeInfo(node.mainPublicKey); // Assert: expect(apiStatus).toEqual(expectedResult.apiStatus); expect(chainInfo).toEqual(expectedResult.chainInfo); - expect(peerStatus).toEqual(expectedResult.peerStatus); expect(mapInfo).toEqual(expectedResult.mapInfo); }; - it('returns peer node status when peer status is present', async () => { - await assertNodeStatus(statisticServiceNodeResponse[0], { - peerStatus: expectedPeerStatus, - apiStatus: {}, - chainInfo: {}, - mapInfo: { - apiStatus: { - isAvailable: undefined - }, - rolesRaw: 1 - } - }); - }); - - it('returns api node status and chain info when api status is present', async () => { - await assertNodeStatus(statisticServiceNodeResponse[1], { - peerStatus: {}, - apiStatus: expectedAPIStatus, - chainInfo: expectedChainInfoStatus, - mapInfo: { + it('returns API node status, chain info and map info', async () => { + await assertNodeStatus( + { + ...nodeWatchServiceNodeResponse[2], + geoLocation: geoLocationCommonField + }, + { apiStatus: { - isAvailable: false + connectionStatus: true, + isHttpsEnabled: true, + restVersion: '2.4.4' }, - rolesRaw: 2 + chainInfo: expectedChainInfoStatus, + mapInfo: { + city: 'ABC City', + continent: 'ABC', + country: 'ABC', + isp: 'ABC Online', + region: 'SN', + coordinates: { + latitude: 10.000, + longitude: 20.000 + }, + rolesRaw: 3, + isApiNode: true + } } - }); + ); }); - it('returns chain info, api and peer node status when both status is present', async () => { - await assertNodeStatus(statisticServiceNodeResponse[2], { - peerStatus: expectedPeerStatus, - apiStatus: expectedAPIStatus, + it('returns Peer node status info, chain info without map info', async () => { + await assertNodeStatus(nodeWatchServiceNodeResponse[0], { + apiStatus: {}, chainInfo: expectedChainInfoStatus, - mapInfo: { - apiStatus: { - isAvailable: false - }, - rolesRaw: 3 - } + mapInfo: {} }); }); - const runLightRestNodeTests = roles => { - it(`returns roles ${roles} node status and light rest status`, async () => { - // Arrange: - const lightNodeResponse = { - roles, - peerStatus: generateNodePeerStatus(true), + const runLightRestNodeTests = lightNode => { + it(`returns roles ${lightNode.roles} node status and light rest status`, async () => { + await assertNodeStatus(lightNode, { apiStatus: { - ...generateNodeApiStatus(true), - nodeStatus: undefined + lightNodeStatus: true, + isHttpsEnabled: true, + restVersion: '2.4.4' }, - ...nodeCommonField - }; - - const expectedLightAPIStatus = { - ...expectedAPIStatus, - lightNodeStatus: true, - connectionStatus: true - }; - delete expectedLightAPIStatus.databaseStatus; - delete expectedLightAPIStatus.apiNodeStatus; - - await assertNodeStatus(lightNodeResponse, { - peerStatus: expectedPeerStatus, - apiStatus: expectedLightAPIStatus, chainInfo: expectedChainInfoStatus, - mapInfo: { - apiStatus: { - isAvailable: true - }, - rolesRaw: roles - } + mapInfo: {} }); }); }; - [1, 4, 5].forEach(roles => runLightRestNodeTests(roles)); + [ + nodeWatchServiceNodeResponse[1], + nodeWatchServiceNodeResponse[3] + ].forEach(lightNode => runLightRestNodeTests(lightNode)); + + runNodeWatchFailResponseTests('getNodeByMainPublicKey', 'getNodeInfo'); + }); - runStatisticServiceFailResponseTests('getNode', 'getNodeInfo'); + describe('getAPINodeList', () => { + it('returns a list of API nodes', async () => { + // Arrange: + jest.spyOn(NodeWatchService, 'getNodes').mockResolvedValue(nodeWatchServiceNodeResponse); + + // Act: + const apiNodeList = await NodeService.getAPINodeList(); + + // Assert: + expect(apiNodeList).toEqual([ + { + ...nodeFormattedCommonField, + restVersion: '2.4.4', + isHealthy: true, + isHttpsEnabled: true, + apiEndpoint: 'http://node.com:3000', + roles: 'Peer Api node', + rolesRaw: 3 + }, + { + ...nodeFormattedCommonField, + restVersion: '2.4.4', + isHealthy: true, + isHttpsEnabled: true, + apiEndpoint: 'http://node.com:3000', + roles: 'Peer Api Voting node', + rolesRaw: 7 + } + ]); + }); }); }); diff --git a/__tests__/infrastructure/NodeWatchService.spec.js b/__tests__/infrastructure/NodeWatchService.spec.js new file mode 100644 index 000000000..75c7b4b4b --- /dev/null +++ b/__tests__/infrastructure/NodeWatchService.spec.js @@ -0,0 +1,86 @@ +import globalConfig from '../../src/config/globalConfig'; +import { NodeWatchService } from '../../src/infrastructure'; +import Axios from 'axios'; + +jest.mock('axios'); + +describe('Node Watch Service', () => { + const runNodeWatchThrowErrorTests = (nodeWatchMethod, params, expectedError) => { + it('throws error when node watch fail response', async () => { + // Arrange: + Axios.get.mockRejectedValue(new Error()); + + // Act + Assert: + await expect(NodeWatchService[nodeWatchMethod](params)).rejects.toThrow(expectedError); + }); + }; + + describe('getNodes', () => { + const mockApiResponse = [ + { id: 1, name: 'Node1' }, + { id: 2, name: 'Node2' } + ]; + + const mockPeerResponse = [ + { id: 3, name: 'PeerNode1' }, + { id: 4, name: 'PeerNode2' } + ]; + + beforeEach(() => { + Axios.get.mockClear(); + + Axios.get.mockImplementation(url => { + if (url.includes('api/symbol/nodes/api')) + return Promise.resolve({ data: mockApiResponse }); + else if (url.includes('api/symbol/nodes/peer')) + return Promise.resolve({ data: mockPeerResponse }); + + }); + }); + it('fetches nodes with default params', async () => { + // Act + const result = await NodeWatchService.getNodes(); + + // Assert + expect(result).toEqual([...mockApiResponse, ...mockPeerResponse]); + expect(Axios.get).toHaveBeenCalledWith(`${globalConfig.endpoints.nodeWatch}/api/symbol/nodes/api?only_ssl=false&limit=0`); + expect(Axios.get).toHaveBeenCalledWith(`${globalConfig.endpoints.nodeWatch}/api/symbol/nodes/peer?only_ssl=false&limit=0`); + }); + + it('fetches nodes with SSL filtering, limit 2 and order random', async () => { + // Act + const result = await NodeWatchService.getNodes(true, 2, 'random'); + + // Assert + const endpointUrl = `${globalConfig.endpoints.nodeWatch}/api/symbol/nodes`; + expect(result).toEqual([...mockApiResponse, ...mockPeerResponse]); + expect(Axios.get).toHaveBeenCalledWith(`${endpointUrl}/api?only_ssl=true&limit=2&order=random`); + expect(Axios.get).toHaveBeenCalledWith(`${endpointUrl}/peer?only_ssl=true&limit=2&order=random`); + }); + + runNodeWatchThrowErrorTests('getNodes', undefined, 'Error fetching from /api/symbol/nodes/api?only_ssl=false&limit=0'); + }); + + describe('getNodeByMainPublicKey', () => { + it('fetches node by main public key', async () => { + // Arrange + const mockResponse = { id: 1, name: 'Node1' }; + const mainPublicKey = 'publicKey123'; + + Axios.get.mockResolvedValue({ data: mockResponse }); + + // Act + const result = await NodeWatchService.getNodeByMainPublicKey(mainPublicKey); + + // Assert + expect(result).toEqual(mockResponse); + expect(Axios.get).toHaveBeenCalledWith(`${globalConfig.endpoints.nodeWatch}/api/symbol/nodes/mainPublicKey/${mainPublicKey}`); + }); + + runNodeWatchThrowErrorTests( + 'getNodeByMainPublicKey', + '1234567890abcdef', + 'Error fetching from /api/symbol/nodes/mainPublicKey/1234567890abcdef' + ); + }); +}); diff --git a/__tests__/store/api.spec.js b/__tests__/store/api.spec.js index 036dc6947..a3ab3969e 100644 --- a/__tests__/store/api.spec.js +++ b/__tests__/store/api.spec.js @@ -26,7 +26,7 @@ const stubMockNode = numberOfNodes => { port: 7900, networkIdentifier: 152, host: `mock_${nodeIndex}.com`, - nodePublicKey: account.publicKey, + mainPublicKey: account.publicKey, address: account.address.plain(), rolesRaw: 3, network: 'TESTNET', diff --git a/src/components/NodesMap.vue b/src/components/NodesMap.vue index 33c3f89fb..e97823f53 100644 --- a/src/components/NodesMap.vue +++ b/src/components/NodesMap.vue @@ -166,7 +166,7 @@ export default { '
' + this.getNameByKey('address') + ': ' + this.formatText(node.address) + '
' + this.getNameByKey('location') + ': ' + this.formatText(node.location) + '
' + - '' + this.getNameByKey('nodeDetailTitle') + + '' + this.getNameByKey('nodeDetailTitle') + ' ' + this.getNameByKey('accountDetailTitle') + '' + ''; @@ -175,7 +175,7 @@ export default { switch (node.rolesRaw) { case 1: - icon = node.apiStatus?.isAvailable ? iconPeerLight : iconPeer; + icon = node.isApiNode ? iconPeerLight : iconPeer; break; case 2: case 3: @@ -183,7 +183,7 @@ export default { break; case 4: case 5: - icon = node.apiStatus?.isAvailable ? iconVotingLight : iconVoting; + icon = node.isApiNode ? iconVotingLight : iconVoting; break; case 6: case 7: diff --git a/src/components/tables/TableView.vue b/src/components/tables/TableView.vue index 42a52b5f4..8298ca492 100644 --- a/src/components/tables/TableView.vue +++ b/src/components/tables/TableView.vue @@ -90,7 +90,7 @@ export default { 'namespaceArtifactId', 'mosaicArtifactId', - 'nodePublicKey' + 'mainPublicKey', ], disableClickValues: [...Object.values(Constants.Message)], changeDecimalColor: [ @@ -201,7 +201,6 @@ export default { 'recipient' === key || 'publicKey' === key || 'signerPublicKey' === key || - 'nodePublicKey' === key || 'mainPublicKey' === key || 'transactionHash' === key || 'ownerAddress' === key || diff --git a/src/config/default.json b/src/config/default.json index f8f51b574..34a3a9a0a 100644 --- a/src/config/default.json +++ b/src/config/default.json @@ -2,7 +2,8 @@ "apiNodePort": 3001, "endpoints": { "marketData": "https://min-api.cryptocompare.com/", - "statisticsService": "https://symbol.services" + "statisticsService": "https://symbol.services", + "nodeWatch": "https://nodewatch.symbol.tools" }, "networkConfig": { "namespaceName": "symbol.xym", diff --git a/src/config/i18n/en-us.json b/src/config/i18n/en-us.json index 5874d91af..11467299a 100644 --- a/src/config/i18n/en-us.json +++ b/src/config/i18n/en-us.json @@ -330,7 +330,7 @@ "nodeDetailTitle": "Node Detail", "nodeHostDetailTitle": "Host Detail", "nodeLocationMapTitle": "Host Location", - "nodePublicKey": "Public Key", + "mainPublicKey": "Public Key", "apiEndpoint": "API Endpoint", "networkGenerationHashSeed": "Network Generation Hash Seed", "networkIdentifier": "Network Identifier", diff --git a/src/config/i18n/es.json b/src/config/i18n/es.json index 004309cc0..d857ef556 100644 --- a/src/config/i18n/es.json +++ b/src/config/i18n/es.json @@ -326,7 +326,7 @@ "nodeDetailTitle": "Node Detail", "nodeHostDetailTitle": "Host Detail", "nodeLocationMapTitle": "Host Location", - "nodePublicKey": "Public Key", + "mainPublicKey": "Public Key", "apiEndpoint": "API Endpoint", "networkGenerationHashSeed": "Network Generation Hash Seed", "networkIdentifier": "Network Identifier", diff --git a/src/config/i18n/ja.json b/src/config/i18n/ja.json index f5c4898e3..bc9a71662 100644 --- a/src/config/i18n/ja.json +++ b/src/config/i18n/ja.json @@ -330,7 +330,7 @@ "nodeDetailTitle": "ノード詳細", "nodeHostDetailTitle": "ホスト詳細", "nodeLocationMapTitle": "ホスト位置", - "nodePublicKey": "公開鍵", + "mainPublicKey": "公開鍵", "apiEndpoint": "APIエンドポイント", "networkGenerationHashSeed": "ネットワークジェネレーションハッシュシード", "networkIdentifier": "ネットワーク識別子", diff --git a/src/config/i18n/ko.json b/src/config/i18n/ko.json index 251b813b6..d893cd6b2 100644 --- a/src/config/i18n/ko.json +++ b/src/config/i18n/ko.json @@ -330,7 +330,7 @@ "nodeDetailTitle": "노드 정보보기", "nodeHostDetailTitle": "호스트 정보보기", "nodeLocationMapTitle": "호스트 위치", - "nodePublicKey": "공개키", + "mainPublicKey": "공개키", "apiEndpoint": "API 엔드포인트", "networkGenerationHashSeed": "네트워크가 생성한 시드 해시", "networkIdentifier": "네트워크 구분자", diff --git a/src/config/i18n/pt.json b/src/config/i18n/pt.json index ac834451a..420e8cde8 100644 --- a/src/config/i18n/pt.json +++ b/src/config/i18n/pt.json @@ -327,7 +327,7 @@ "nodeDetailTitle": "Node Detail", "nodeHostDetailTitle": "Host Detail", "nodeLocationMapTitle": "Host Location", - "nodePublicKey": "Public Key", + "mainPublicKey": "Public Key", "apiEndpoint": "API Endpoint", "networkGenerationHashSeed": "Network Generation Hash Seed", "networkIdentifier": "Network Identifier", diff --git a/src/config/i18n/ru.json b/src/config/i18n/ru.json index 6589034ce..f02bcd7f2 100644 --- a/src/config/i18n/ru.json +++ b/src/config/i18n/ru.json @@ -330,7 +330,7 @@ "nodeDetailTitle": "Детали ноды", "nodeHostDetailTitle": "Детали хоста", "nodeLocationMapTitle": "Местонахождение Хоста", - "nodePublicKey": "Публичный ключ", + "mainPublicKey": "Публичный ключ", "apiEndpoint": "Конечная точка API интерфейса", "networkGenerationHashSeed": "Генерация сети Хеш-сид", "networkIdentifier": "Идентификатор сети", diff --git a/src/config/i18n/ua.json b/src/config/i18n/ua.json index 84987133a..f0b9ed198 100644 --- a/src/config/i18n/ua.json +++ b/src/config/i18n/ua.json @@ -326,7 +326,7 @@ "nodeDetailTitle": "Node Detail", "nodeHostDetailTitle": "Host Detail", "nodeLocationMapTitle": "Host Location", - "nodePublicKey": "Public Key", + "mainPublicKey": "Public Key", "apiEndpoint": "API Endpoint", "networkGenerationHashSeed": "Network Generation Hash Seed", "networkIdentifier": "Network Identifier", diff --git a/src/config/i18n/zh.json b/src/config/i18n/zh.json index c7a6cbe63..dfae37806 100644 --- a/src/config/i18n/zh.json +++ b/src/config/i18n/zh.json @@ -326,7 +326,7 @@ "nodeDetailTitle": "Node Detail", "nodeHostDetailTitle": "Host Detail", "nodeLocationMapTitle": "Host Location", - "nodePublicKey": "Public Key", + "mainPublicKey": "Public Key", "apiEndpoint": "API Endpoint", "networkGenerationHashSeed": "Network Generation Hash Seed", "networkIdentifier": "Network Identifier", diff --git a/src/config/key-redirects.json b/src/config/key-redirects.json index 93fce9993..f4993d853 100644 --- a/src/config/key-redirects.json +++ b/src/config/key-redirects.json @@ -63,5 +63,5 @@ "namespaceArtifactId": "namespaces", "node": "nodes", - "nodePublicKey": "nodes" -} \ No newline at end of file + "mainPublicKey": "nodes" +} diff --git a/src/config/pages/node-detail.json b/src/config/pages/node-detail.json index 4e9f43b5f..96cfa6b20 100644 --- a/src/config/pages/node-detail.json +++ b/src/config/pages/node-detail.json @@ -27,7 +27,7 @@ "networkGenerationHashSeed", "network", "networkIdentifier", - "publicKey", + "mainPublicKey", "address" ] }, @@ -49,20 +49,17 @@ "type": "CardTable", "title": "nodeHostDetailTitle", "managerGetter": "node/info", - "dataGetter": "node/hostDetail", + "dataGetter": "node/geoLocation", "hideDependOnGetter": "node/hostInfoManager", "errorMessage": "nodeDetailError", "pagination": "none", "fields": [ - "ip", - "organization", - "as", "continent", "country", "region", "city", - "district", - "zip" + "isp" + ] }, { @@ -76,26 +73,9 @@ "hideEmptyData": true, "fields": [ "connectionStatus", - "databaseStatus", - "apiNodeStatus", "lightNodeStatus", "isHttpsEnabled", - "restVersion", - "lastStatusCheck" - ] - }, - { - "layoutOptions": "adaptive", - "type": "CardTable", - "title": "nodePeerStatusTitle", - "managerGetter": "node/info", - "dataGetter": "node/peerStatus", - "errorMessage": "nodeDetailError", - "pagination": "none", - "hideEmptyData": true, - "fields": [ - "connectionStatus", - "lastStatusCheck" + "restVersion" ] }, { diff --git a/src/config/pages/node-list.json b/src/config/pages/node-list.json index 6b081076a..e6890838e 100644 --- a/src/config/pages/node-list.json +++ b/src/config/pages/node-list.json @@ -41,14 +41,14 @@ "host", "friendlyName", "roles", - "nodePublicKey", + "mainPublicKey", "chainInfo", "softwareVersion" ], "mobileFields": [ "host", "friendlyName", - "nodePublicKey" + "mainPublicKey" ] } ] diff --git a/src/infrastructure/NodeService.js b/src/infrastructure/NodeService.js index a12c81a53..5865b3b9b 100644 --- a/src/infrastructure/NodeService.js +++ b/src/infrastructure/NodeService.js @@ -19,7 +19,7 @@ import http from './http'; import Constants from '../config/constants'; import helper from '../helper'; -import moment from 'moment'; +import { NodeWatchService } from '../infrastructure'; import * as symbol from 'symbol-sdk'; class NodeService { @@ -67,50 +67,55 @@ class NodeService { * @param {object} nodeInfo NodeInfoDTO. * @returns {object} readable NodeInfo. */ - static formatNodeInfo = nodeInfo => ({ - ...nodeInfo, - nodePublicKey: nodeInfo.publicKey, - address: symbol.Address.createFromPublicKey( - nodeInfo.publicKey, - nodeInfo.networkIdentifier - ).plain(), - rolesRaw: nodeInfo.roles, - roles: [1,4,5].includes(nodeInfo.roles) && nodeInfo.apiStatus?.isAvailable - ? Constants.RoleType[nodeInfo.roles] + ' (light)' - : Constants.RoleType[nodeInfo.roles], - network: Constants.NetworkType[nodeInfo.networkIdentifier], - version: helper.formatNodeVersion(nodeInfo.version), - apiEndpoint: - 2 === nodeInfo.roles || - 3 === nodeInfo.roles || - 6 === nodeInfo.roles || - 7 === nodeInfo.roles - ? nodeInfo.apiStatus.restGatewayUrl - : Constants.Message.UNAVAILABLE - }); + static formatNodeInfo = nodeInfo => { + const { hostname, port } = '' !== nodeInfo.endpoint + ? new URL(nodeInfo.endpoint) + : { hostname: 'N/A', port: 'N/A' }; + + return { + finalizedEpoch: nodeInfo.finalizedEpoch, + finalizedHash: nodeInfo.finalizedHash, + finalizedHeight: nodeInfo.finalizedHeight, + finalizedPoint: nodeInfo.finalizedPoint, + height: nodeInfo.height, + mainPublicKey: nodeInfo.mainPublicKey, + isHealthy: nodeInfo.isHealthy, + restVersion: nodeInfo.restVersion, + isHttpsEnabled: nodeInfo.isSslEnabled, + friendlyName: nodeInfo.name, + geoLocation: nodeInfo.geoLocation, + host: hostname, + version: nodeInfo.version, + port, + address: symbol.Address.createFromPublicKey( + nodeInfo.mainPublicKey, + http.networkType + ).plain(), + rolesRaw: nodeInfo.roles, + roles: [1,4,5].includes(nodeInfo.roles) && null != nodeInfo.restVersion + ? Constants.RoleType[nodeInfo.roles] + ' (light)' + : Constants.RoleType[nodeInfo.roles], + network: Constants.NetworkType[http.networkType], + networkIdentifier: http.networkType, + apiEndpoint: null != nodeInfo.restVersion ? nodeInfo.endpoint : Constants.Message.UNAVAILABLE, + networkGenerationHashSeed: http.generationHash + }; + }; /** - * Get available node list from statistic service. + * Get available node list from node watch service. * @returns {array} NodeInfo[] */ static getAvailableNodes = async () => { try { - const nodePeers = await http.statisticServiceRestClient().getNodes(); + const nodePeers = await NodeWatchService.getNodes(); return nodePeers - .filter(({ apiStatus, roles, peerStatus }) => { - if (1 === roles || 4 === roles || 5 === roles) - return peerStatus?.isAvailable; - else if (3 === roles || 6 === roles || 7 === roles) - return apiStatus?.isAvailable || peerStatus?.isAvailable; - else - return apiStatus?.isAvailable; - }) .map(nodeInfo => this.formatNodeInfo(nodeInfo)) .sort((a, b) => a.friendlyName.localeCompare(b.friendlyName)); } catch (e) { console.error(e); - throw Error('Statistics service getNodes error'); + throw Error('node watch getNodes error'); } }; @@ -132,33 +137,28 @@ class NodeService { node['softwareVersion'] = { version: el.version }; - if (el.apiStatus) { - const { - chainHeight, - finalization, - lastStatusCheck, - restVersion, - isHttpsEnabled - } = el.apiStatus; - - node['chainInfo'] = { - chainHeight, - finalizationHeight: finalization?.height, - lastStatusCheck - }; + node['chainInfo'] = { + chainHeight: el.height, + finalizationHeight: el.finalizedHeight + }; - node['softwareVersion'] = { - ...node.softwareVersion, - restVersion, - isHttpsEnabled - }; - } else { - node['chainInfo'] = {}; - } + node['softwareVersion'] = { + ...node.softwareVersion, + restVersion: el.restVersion, + isHttpsEnabled: el.isSslEnabled + }; - if (node?.hostDetail) { - node = { ...node, ...node.hostDetail }; - delete node.hostDetail; + if (node?.geoLocation) { + node = { + ...node, + coordinates: { + latitude: node.geoLocation.lat, + longitude: node.geoLocation.lon + }, + location: node.geoLocation.city + ', ' + node.geoLocation.region + ', ' + node.geoLocation.country, + isApiNode: null != node.restVersion + }; + delete node.geoLocation; } return node; @@ -168,87 +168,70 @@ class NodeService { static getNodeInfo = async publicKey => { try { - const node = await http.statisticServiceRestClient().getNode(publicKey); + const node = await NodeWatchService.getNodeByMainPublicKey(publicKey); + const formattedNode = this.formatNodeInfo(node); - if (formattedNode?.apiStatus) { - const { - finalization, - chainHeight, - lastStatusCheck, - nodeStatus, - isAvailable, - isHttpsEnabled, - restVersion - } = formattedNode.apiStatus; + const { + finalizedEpoch, + finalizedHash, + finalizedHeight, + finalizedPoint, + height, + isHealthy, + isHttpsEnabled, + restVersion + } = formattedNode; + + // Chain info + formattedNode.chainInfo = { + height: height, + finalizedHeight: finalizedHeight, + finalizationEpoch: finalizedEpoch, + finalizationPoint: finalizedPoint, + finalizedHash: finalizedHash + }; + + formattedNode.apiStatus = {}; - // Api status + // Only API nodes have database status + if ([2, 3, 6, 7].includes(formattedNode.rolesRaw)) { formattedNode.apiStatus = { - connectionStatus: isAvailable, + connectionStatus: isHealthy || Constants.Message.UNAVAILABLE, isHttpsEnabled, - restVersion, - lastStatusCheck: moment - .utc(lastStatusCheck) - .format('YYYY-MM-DD HH:mm:ss') + restVersion }; - - // Only API nodes have database status - if ([2, 3, 6, 7].includes(node.roles)) { - formattedNode.apiStatus = { - ...formattedNode.apiStatus, - apiNodeStatus: - 'up' === nodeStatus?.apiNode || Constants.Message.UNAVAILABLE, - databaseStatus: 'up' === nodeStatus?.db || Constants.Message.UNAVAILABLE - }; - } else { + } else { + if (null != restVersion) { formattedNode.apiStatus = { - ...formattedNode.apiStatus, - lightNodeStatus: isAvailable || Constants.Message.UNAVAILABLE + lightNodeStatus: true, + isHttpsEnabled, + restVersion }; - }; - - // Chain info - formattedNode.chainInfo = { - height: chainHeight, - finalizedHeight: finalization?.height, - finalizationEpoch: finalization?.epoch, - finalizationPoint: finalization?.point, - finalizedHash: finalization?.hash, - lastStatusCheck: moment - .utc(lastStatusCheck) - .format('YYYY-MM-DD HH:mm:ss') - }; - } else { - formattedNode.apiStatus = {}; - formattedNode.chainInfo = {}; - } - - if (formattedNode?.peerStatus) { - const { isAvailable, lastStatusCheck } = formattedNode.peerStatus; + } + }; - formattedNode.peerStatus = { - connectionStatus: isAvailable, - lastStatusCheck: moment - .utc(lastStatusCheck) - .format('YYYY-MM-DD HH:mm:ss') + // Map info used for create a marker in the map + if (formattedNode?.geoLocation) { + const { lat, lon, ...geoInfo } = formattedNode.geoLocation; + + formattedNode.mapInfo = { + ...geoInfo, + coordinates: { + latitude: lat, + longitude: lon + }, + rolesRaw: formattedNode.rolesRaw, + isApiNode: null != formattedNode.restVersion }; } else { - formattedNode.peerStatus = {}; + formattedNode.mapInfo = {}; } - // Map info used for create a marker in the map - formattedNode.mapInfo = { - ...formattedNode.hostDetail, - rolesRaw: formattedNode.rolesRaw, - apiStatus: { - isAvailable: node.apiStatus?.isAvailable - } - }; - return formattedNode; } catch (e) { console.error(e); - throw Error('Statistics service getNode error'); + throw Error('node watch getNodeByMainPublicKey error'); } }; @@ -304,7 +287,7 @@ class NodeService { roles: node.roles, network: node.network, networkGenerationHashSeed: node.networkGenerationHashSeed, - nodePublicKey: node.nodePublicKey, + mainPublicKey: node.mainPublicKey, chainHeight: node.chainInfo.chainHeight, finalizationHeight: node.chainInfo.finalizationHeight, version: node.version @@ -313,32 +296,16 @@ class NodeService { return helper.convertArrayToCSV(formattedData); }; - /** - * Gets node list from statistics service. - * @param {string} filter (optional) 'preferred | suggested'. - * @param {number} limit (optional) number of records. - * @param {boolean} ssl (optional) return ssl ready node. - * @returns {array} nodes - */ - static getNodeList = async (filter, limit, ssl) => { - try { - return await http - .statisticServiceRestClient() - .getNodes(filter, limit, ssl); - } catch (e) { - throw Error('Statistics service getNodeHeightStats error: ', e); - } - }; - /** * Get API node list dataset into Vue Component. * @returns {array} API Node list object for Vue component. */ static getAPINodeList = async () => { - // get 30 ssl ready nodes from statistics service the list - const nodes = await this.getNodeList('suggested', 30, true); + // get 30 ssl ready nodes from node watch service + const nodes = await NodeWatchService.getNodes(true, 30, 'random'); return nodes + .filter(node => node.isHealthy && node.isSslEnabled) .map(nodeInfo => this.formatNodeInfo(nodeInfo)) .sort((a, b) => a.friendlyName.localeCompare(b.friendlyName)); }; diff --git a/src/infrastructure/NodeWatchService.js b/src/infrastructure/NodeWatchService.js new file mode 100644 index 000000000..3198ce972 --- /dev/null +++ b/src/infrastructure/NodeWatchService.js @@ -0,0 +1,33 @@ +import globalConfig from '../config/globalConfig'; +import Axios from 'axios'; + +class NodeWatchService { + static async getNodes(onlySSL = false, limit = 0, order = null) { + const params = `only_ssl=${onlySSL}&limit=${limit}${order ? `&order=${order}` : ''}`; + + const [apiNodesResponse, peerNodesResponse] = await Promise.all([ + this.get(`/api/symbol/nodes/api?${params}`), + this.get(`/api/symbol/nodes/peer?${params}`) + ]); + + return [...apiNodesResponse.data, ...peerNodesResponse.data]; + } + + static async getNodeByMainPublicKey(mainPublicKey) { + const response = await this.get(`/api/symbol/nodes/mainPublicKey/${mainPublicKey}`); + + return response.data; + } + + static async get(route) { + try { + const response = await Axios.get(`${globalConfig.endpoints.nodeWatch}${route}`); + return response; + } catch (error) { + console.error('Error fetching nodes:', error); + throw Error(`Error fetching from ${route}`); + } + } +} + +export default NodeWatchService; diff --git a/src/infrastructure/index.js b/src/infrastructure/index.js index 459e0a195..7f4ba43a6 100644 --- a/src/infrastructure/index.js +++ b/src/infrastructure/index.js @@ -12,6 +12,7 @@ import MultisigService from './MultisigService'; import NamespaceService from './NamespaceService'; import NetworkService from './NetworkService'; import NodeService from './NodeService'; +import NodeWatchService from './NodeWatchService'; import ReceiptExtractor from './ReceiptExtractor'; import ReceiptService from './ReceiptService'; import RestrictionService from './RestrictionService'; @@ -20,6 +21,7 @@ import TransactionService from './TransactionService'; export { NodeService, + NodeWatchService, AccountService, MetadataService, RestrictionService, diff --git a/src/store/node.js b/src/store/node.js index d5b1d6118..09b5ae0d2 100644 --- a/src/store/node.js +++ b/src/store/node.js @@ -57,10 +57,9 @@ export default { getInitialized: state => state.initialized, ...getGettersFromManagers(managers), mapInfo: state => [ state.info?.data?.mapInfo ], - peerStatus: state => state.info?.data?.peerStatus, apiStatus: state => state.info?.data?.apiStatus, chainInfo: state => state.info?.data?.chainInfo, - hostDetail: state => state.info?.data?.hostDetail, + geoLocation: state => state.info?.data?.geoLocation, hostInfoManager: (state, getters) => ({ loading: getters.timeline?.loading || getters.info?.loading, From 2c188c16e468428b36fce4a5a0dea821cb8dcbb6 Mon Sep 17 00:00:00 2001 From: Anthony Law Date: Tue, 26 Aug 2025 12:59:24 +0800 Subject: [PATCH 2/3] [explorer] task: migrate statistics pages to node watch services Problem: migrate explorer to node watch services Solution: Removed everything related library and URL for the statistic services. Added query node count endpoint from NodeWatch services. Added bubble chart config. Convert 2 bar charts (height and finalized height) into one single bubble chart. Added more unit tests. --- __tests__/TestHelper.js | 5 +- __tests__/infrastructure/NodeService.spec.js | 111 +++++++++++--- package-lock.json | 6 - package.json | 1 - src/components/Chart.vue | 10 +- ...odeHeightAndFinalizedHeightStatsWidget.vue | 79 ++++++++++ .../widgets/NodeHeightStatsWidget.vue | 135 ------------------ src/config/default.json | 1 - src/config/i18n/en-us.json | 3 +- src/config/i18n/es.json | 3 +- src/config/i18n/ja.json | 3 +- src/config/i18n/ko.json | 3 +- src/config/i18n/pt.json | 3 +- src/config/i18n/ru.json | 3 +- src/config/i18n/ua.json | 3 +- src/config/i18n/zh.json | 3 +- src/infrastructure/NodeService.js | 96 ++++++++++--- src/infrastructure/NodeWatchService.js | 5 + src/infrastructure/StatisticService.js | 22 +-- src/infrastructure/http.js | 21 --- src/store/node.js | 3 +- src/store/statistic.js | 8 +- src/views/Statistics.vue | 12 +- 23 files changed, 284 insertions(+), 255 deletions(-) create mode 100644 src/components/widgets/NodeHeightAndFinalizedHeightStatsWidget.vue delete mode 100644 src/components/widgets/NodeHeightStatsWidget.vue diff --git a/__tests__/TestHelper.js b/__tests__/TestHelper.js index 6adcce7b8..3de8ded54 100644 --- a/__tests__/TestHelper.js +++ b/__tests__/TestHelper.js @@ -375,7 +375,10 @@ const TestHelper = { finalizedPoint: 1, geoLocation: null, height: 120, - name: 'node' + name: 'node', + restVersion: null, + isHealthy: null, + isSslEnabled: null }, geoLocationCommonField: { city: 'ABC City', diff --git a/__tests__/infrastructure/NodeService.spec.js b/__tests__/infrastructure/NodeService.spec.js index a1d465dd7..8ae44b0df 100644 --- a/__tests__/infrastructure/NodeService.spec.js +++ b/__tests__/infrastructure/NodeService.spec.js @@ -10,46 +10,46 @@ describe('Node Service', () => { const nodeWatchServiceNodeResponse = [ { + ...nodeCommonField, roles: 1, restVersion: null, isHealthy: null, - isSslEnabled: null, - ...nodeCommonField + isSslEnabled: null }, { + ...nodeCommonField, roles: 1, // Peer node (light) restVersion: '2.4.4', isHealthy: null, - isSslEnabled: true, - ...nodeCommonField + isSslEnabled: true }, { + ...nodeCommonField, roles: 3, restVersion: '2.4.4', isHealthy: true, - isSslEnabled: true, - ...nodeCommonField + isSslEnabled: true }, { + ...nodeCommonField, roles: 5, // Peer Voting node (light) restVersion: '2.4.4', isHealthy: null, - isSslEnabled: true, - ...nodeCommonField + isSslEnabled: true }, { + ...nodeCommonField, roles: 5, restVersion: null, isHealthy: null, - isSslEnabled: null, - ...nodeCommonField + isSslEnabled: null }, { + ...nodeCommonField, roles: 7, restVersion: '2.4.4', isHealthy: true, - isSslEnabled: true, - ...nodeCommonField + isSslEnabled: true } ]; @@ -72,15 +72,14 @@ describe('Node Service', () => { networkGenerationHashSeed: '57F7DA205008026C776CB6AED843393F04CD458E0AA2D9F1D5F31A402072B2D6' }; - const runNodeWatchFailResponseTests = (nodeWatchMethod, NodeServiceMethod) => { - it('throws error when node watch fail response', async () => { + const runNodeServiceThrowErrorTests = (nodeServiceMethod, params, expectedError) => { + it('throws error when node service fail response', async () => { // Arrange: - const error = new Error(`node watch ${nodeWatchMethod} error`); - - jest.spyOn(NodeWatchService, nodeWatchMethod).mockRejectedValue(error); + jest.spyOn(NodeWatchService, 'getNodes').mockRejectedValue(new Error()); + jest.spyOn(NodeWatchService, 'getNodeByMainPublicKey').mockRejectedValue(new Error()); // Act + Assert: - await expect(NodeService[NodeServiceMethod]()).rejects.toThrow(error); + await expect(NodeService[nodeServiceMethod](params)).rejects.toThrow(expectedError); }); }; @@ -151,7 +150,7 @@ describe('Node Service', () => { ]); }); - runNodeWatchFailResponseTests('getNodes', 'getAvailableNodes'); + runNodeServiceThrowErrorTests('getAvailableNodes', undefined, 'Failed to get available nodes'); }); describe('getNodeStats', () => { @@ -258,7 +257,7 @@ describe('Node Service', () => { nodeWatchServiceNodeResponse[3] ].forEach(lightNode => runLightRestNodeTests(lightNode)); - runNodeWatchFailResponseTests('getNodeByMainPublicKey', 'getNodeInfo'); + runNodeServiceThrowErrorTests('getNodeInfo', 'public key 123', 'Failed to get node info for public key public key 123'); }); describe('getAPINodeList', () => { @@ -292,4 +291,76 @@ describe('Node Service', () => { ]); }); }); + + describe('getNodeHeightAndFinalizedHeightStats', () => { + it('returns node height and finalized height count and group by version', async () => { + // Arrange: + const mockApiResponse = [ + { + ...nodeCommonField, + version: '1.0.3.6', + height: 120, + finalizedHeight: 100 + }, + { + ...nodeCommonField, + version: '1.0.3.6', + height: 120, + finalizedHeight: 100 + }, + { + ...nodeCommonField, + version: '1.0.3.5', + height: 120, + finalizedHeight: 99 + }, + { + ...nodeCommonField, + version: '1.0.3.5', + height: 120, + finalizedHeight: 100 + }, + { + ...nodeCommonField, + version: '1.0.3.4', + height: 120, + finalizedHeight: 100 + } + ]; + + jest.spyOn(NodeWatchService, 'getNodes').mockResolvedValue(mockApiResponse); + + // Act: + const result = await NodeService.getNodeHeightAndFinalizedHeightStats(); + + // Assert: + expect(result).toEqual([ + { + 'data': [{'x': 120, 'y': 2, 'z': 2}], + 'name': '1.0.3.6 - Height' + }, + { + 'data': [{'x': 100, 'y': 2, 'z': 2}], + 'name': '1.0.3.6 - Finalized Height'}, + { + 'data': [{'x': 120, 'y': 2, 'z': 2}], + 'name': '1.0.3.5 - Height' + }, + { + 'data': [{'x': 99, 'y': 1, 'z': 1}, {'x': 100, 'y': 1, 'z': 1}], + 'name': '1.0.3.5 - Finalized Height' + }, + { + 'data': [{'x': 120, 'y': 1, 'z': 1}], + 'name': '1.0.3.4 - Height' + }, + { + 'data': [{'x': 100, 'y': 1, 'z': 1}], + 'name': '1.0.3.4 - Finalized Height' + } + ]); + }); + + runNodeServiceThrowErrorTests('getNodeHeightAndFinalizedHeightStats', undefined, 'Failed to get node height stats'); + }); }); diff --git a/package-lock.json b/package-lock.json index 5494798a3..90ed4f714 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "nem-sdk": "^1.6.8", "net": "^1.0.2", "symbol-sdk": "^2.0.3", - "symbol-statistics-service-typescript-fetch-client": "^1.1.5", "tls": "^0.0.1", "url-parse": "^1.5.10", "vue": "^2.6.14", @@ -23939,11 +23938,6 @@ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", "license": "Unlicense" }, - "node_modules/symbol-statistics-service-typescript-fetch-client": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/symbol-statistics-service-typescript-fetch-client/-/symbol-statistics-service-typescript-fetch-client-1.1.5.tgz", - "integrity": "sha512-hcVD9jq+S2kZCYaXTDglU43y3Dn5K6W3O45Y/pMAfiuYDCdVaHczAPDE/ZKGo0kZXy69e1sn/riAzfDfn8yFBw==" - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index bfc436d3f..eb06c82e7 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "nem-sdk": "^1.6.8", "net": "^1.0.2", "symbol-sdk": "^2.0.3", - "symbol-statistics-service-typescript-fetch-client": "^1.1.5", "tls": "^0.0.1", "url-parse": "^1.5.10", "vue": "^2.6.14", diff --git a/src/components/Chart.vue b/src/components/Chart.vue index 74d413f97..ecb415975 100644 --- a/src/components/Chart.vue +++ b/src/components/Chart.vue @@ -149,6 +149,11 @@ export default { dataLabels: { position: 'top' } + }, + bubble: { + zScaling: true, + minBubbleRadius: 10, + maxBubbleRadius: 50 } }, title: { @@ -189,7 +194,10 @@ export default { : {} }, tooltip: { - enabled: true + enabled: true, + z: { + title: 'Count: ' + }, }, legend: { show: true, diff --git a/src/components/widgets/NodeHeightAndFinalizedHeightStatsWidget.vue b/src/components/widgets/NodeHeightAndFinalizedHeightStatsWidget.vue new file mode 100644 index 000000000..48992cc2f --- /dev/null +++ b/src/components/widgets/NodeHeightAndFinalizedHeightStatsWidget.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/widgets/NodeHeightStatsWidget.vue b/src/components/widgets/NodeHeightStatsWidget.vue deleted file mode 100644 index f44b0d587..000000000 --- a/src/components/widgets/NodeHeightStatsWidget.vue +++ /dev/null @@ -1,135 +0,0 @@ - - - diff --git a/src/config/default.json b/src/config/default.json index 34a3a9a0a..5188f549c 100644 --- a/src/config/default.json +++ b/src/config/default.json @@ -2,7 +2,6 @@ "apiNodePort": 3001, "endpoints": { "marketData": "https://min-api.cryptocompare.com/", - "statisticsService": "https://symbol.services", "nodeWatch": "https://nodewatch.symbol.tools" }, "networkConfig": { diff --git a/src/config/i18n/en-us.json b/src/config/i18n/en-us.json index 11467299a..1e7bba8c6 100644 --- a/src/config/i18n/en-us.json +++ b/src/config/i18n/en-us.json @@ -383,8 +383,7 @@ "allNodes": "Total count", "nodeCountByRoles": "Count By Roles", "chainInfo": "Chain Info", - "nodeHeightStatsTitle": "Count of nodes by chain height", - "nodeFinalizedHeightStatsTitle": "Count of nodes by finalized height", + "nodeHeightAndFinalizedHeightStatsTitle": "Count of nodes Heights and finalized heights", "viewMoreStatistics": "View More Stats", "nodeCountChartTitle": "Node count over time", "timestamp": "Timestamp", diff --git a/src/config/i18n/es.json b/src/config/i18n/es.json index d857ef556..61d181c2d 100644 --- a/src/config/i18n/es.json +++ b/src/config/i18n/es.json @@ -379,8 +379,7 @@ "allNodes": "Total count", "nodeCountByRoles": "Count By Roles", "chainInfo": "Chain Info", - "nodeHeightStatsTitle": "Count of nodes by chain height", - "nodeFinalizedHeightStatsTitle": "Count of nodes by finalized height", + "nodeHeightAndFinalizedHeightStatsTitle": "Count of nodes Heights and finalized heights", "viewMoreStatistics": "View More Stats", "nodeCountChartTitle": "Node count over time", "timestamp": "Timestamp", diff --git a/src/config/i18n/ja.json b/src/config/i18n/ja.json index bc9a71662..112332c20 100644 --- a/src/config/i18n/ja.json +++ b/src/config/i18n/ja.json @@ -383,8 +383,7 @@ "allNodes": "合計数", "nodeCountByRoles": "ロールごとの数", "chainInfo": "チェーン情報", - "nodeHeightStatsTitle": "Count of nodes by chain height", - "nodeFinalizedHeightStatsTitle": "Count of nodes by finalized height", + "nodeHeightAndFinalizedHeightStatsTitle": "Count of nodes Heights and finalized heights", "viewMoreStatistics": "詳細を表示", "nodeCountChartTitle": "Node count over time", "timestamp": "タイムスタンプ", diff --git a/src/config/i18n/ko.json b/src/config/i18n/ko.json index d893cd6b2..1b54f5d77 100644 --- a/src/config/i18n/ko.json +++ b/src/config/i18n/ko.json @@ -383,8 +383,7 @@ "allNodes": "총 개수", "nodeCountByRoles": "역할 개수", "chainInfo": "체인 정보", - "nodeHeightStatsTitle": "체인 높이의 총 노드 수", - "nodeFinalizedHeightStatsTitle": "최종화 높이의 총 노드 수", + "nodeHeightAndFinalizedHeightStatsTitle": "노드 높이와 최종화 높이의 수", "viewMoreStatistics": "상태 더보기", "nodeCountChartTitle": "노드 카운트 시간 초과", "timestamp": "타임스탬프", diff --git a/src/config/i18n/pt.json b/src/config/i18n/pt.json index 420e8cde8..323dd4379 100644 --- a/src/config/i18n/pt.json +++ b/src/config/i18n/pt.json @@ -380,8 +380,7 @@ "allNodes": "Total count", "nodeCountByRoles": "Count By Roles", "chainInfo": "Chain Info", - "nodeHeightStatsTitle": "Count of nodes by chain height", - "nodeFinalizedHeightStatsTitle": "Count of nodes by finalized height", + "nodeHeightAndFinalizedHeightStatsTitle": "Count of nodes Heights and finalized heights", "viewMoreStatistics": "View More Stats", "nodeCountChartTitle": "Node count over time", "timestamp": "Timestamp", diff --git a/src/config/i18n/ru.json b/src/config/i18n/ru.json index f02bcd7f2..b0b6a7428 100644 --- a/src/config/i18n/ru.json +++ b/src/config/i18n/ru.json @@ -383,8 +383,7 @@ "allNodes": "Общее количество", "nodeCountByRoles": "Подсчет по ролям", "chainInfo": "Информация о сети", - "nodeHeightStatsTitle": "Количество нод по высоте цепи", - "nodeFinalizedHeightStatsTitle": "Количество нод по завершенной высоте", + "nodeHeightAndFinalizedHeightStatsTitle": "Количество нод по высоте и завершенной высоте", "viewMoreStatistics": "Просмотр Дополнительной Статистики", "nodeCountChartTitle": "Количество нод с течением времени", "timestamp": "Отметка времени", diff --git a/src/config/i18n/ua.json b/src/config/i18n/ua.json index f0b9ed198..87cfa0864 100644 --- a/src/config/i18n/ua.json +++ b/src/config/i18n/ua.json @@ -379,8 +379,7 @@ "allNodes": "Total count", "nodeCountByRoles": "Count By Roles", "chainInfo": "Chain Info", - "nodeHeightStatsTitle": "Count of nodes by chain height", - "nodeFinalizedHeightStatsTitle": "Count of nodes by finalized height", + "nodeHeightAndFinalizedHeightStatsTitle": "Count of nodes Heights and finalized heights", "viewMoreStatistics": "View More Stats", "nodeCountChartTitle": "Node count over time", "timestamp": "Timestamp", diff --git a/src/config/i18n/zh.json b/src/config/i18n/zh.json index dfae37806..de3375db4 100644 --- a/src/config/i18n/zh.json +++ b/src/config/i18n/zh.json @@ -379,8 +379,7 @@ "allNodes": "Total count", "nodeCountByRoles": "Count By Roles", "chainInfo": "Chain Info", - "nodeHeightStatsTitle": "Count of nodes by chain height", - "nodeFinalizedHeightStatsTitle": "Count of nodes by finalized height", + "nodeHeightAndFinalizedHeightStatsTitle": "Count of nodes Heights and finalized heights", "viewMoreStatistics": "View More Stats", "nodeCountChartTitle": "Node count over time", "timestamp": "Timestamp", diff --git a/src/infrastructure/NodeService.js b/src/infrastructure/NodeService.js index 5865b3b9b..739a2ec84 100644 --- a/src/infrastructure/NodeService.js +++ b/src/infrastructure/NodeService.js @@ -115,7 +115,7 @@ class NodeService { .sort((a, b) => a.friendlyName.localeCompare(b.friendlyName)); } catch (e) { console.error(e); - throw Error('node watch getNodes error'); + throw Error('Failed to get available nodes'); } }; @@ -231,7 +231,7 @@ class NodeService { return formattedNode; } catch (e) { console.error(e); - throw Error('node watch getNodeByMainPublicKey error'); + throw Error('Failed to get node info for public key ' + publicKey); } }; @@ -251,28 +251,84 @@ class NodeService { return nodeTypes; }; - static getNodeHeightStats = async () => { + static getNodeHeightAndFinalizedHeightStats = async () => { try { - const data = await http.statisticServiceRestClient().getNodeHeightStats(); - - return [ - { - name: 'Height', - data: data.height.map(el => ({ - x: '' + parseInt(el.value), - y: parseInt(el.count) + const data = await NodeWatchService.getNodes(); + + const nodesByVersion = {}; + + data.forEach(node => { + const {version} = node; + + // Initialize the array for this version if it doesn't exist yet + if (!nodesByVersion[version]) { + nodesByVersion[version] = { + heights: [ + { + height: node.height, + count: 1 + } + ], + finalizedHeights: [ + { + finalizedHeight: node.finalizedHeight, + count: 1 + } + ] + }; + } else { + // Check if height already exists + const existingHeight = nodesByVersion[version].heights.find(h => h.height === node.height); + if (existingHeight) { + existingHeight.count++; + } else { + nodesByVersion[version].heights.push({ + height: node.height, + count: 1 + }); + } + + // Check if finalized height already exists + const existingFinalizedHeight = nodesByVersion[version] + .finalizedHeights.find(h => h.finalizedHeight === node.finalizedHeight); + if (existingFinalizedHeight) { + existingFinalizedHeight.count++; + } else { + nodesByVersion[version].finalizedHeights.push({ + finalizedHeight: node.finalizedHeight, + count: 1 + }); + } + } + }); + + const result = []; + + Object.keys(nodesByVersion).forEach(version => { + // Add height series for this version + result.push({ + name: `${version} - Height`, + data: nodesByVersion[version].heights.map(item => ({ + x: item.height, + y: item.count, + z: item.count })) - }, - { - name: 'Finalized Height', - data: data.finalizedHeight.map(el => ({ - x: '' + parseInt(el.value), - y: parseInt(el.count) + }); + + // Add finalized height series for this version + result.push({ + name: `${version} - Finalized Height`, + data: nodesByVersion[version].finalizedHeights.map(item => ({ + x: item.finalizedHeight, + y: item.count, + z: item.count })) - } - ]; + }); + }); + + return result; } catch (e) { - throw Error('Statistics service getNodeHeightStats error: ', e); + throw Error('Failed to get node height stats'); } }; diff --git a/src/infrastructure/NodeWatchService.js b/src/infrastructure/NodeWatchService.js index 3198ce972..609866cf0 100644 --- a/src/infrastructure/NodeWatchService.js +++ b/src/infrastructure/NodeWatchService.js @@ -19,6 +19,11 @@ class NodeWatchService { return response.data; } + static async getNodeCount() { + const response = await this.get('/api/symbol/nodes/count'); + return response.data; + } + static async get(route) { try { const response = await Axios.get(`${globalConfig.endpoints.nodeWatch}${route}`); diff --git a/src/infrastructure/StatisticService.js b/src/infrastructure/StatisticService.js index b07079877..5669723b4 100644 --- a/src/infrastructure/StatisticService.js +++ b/src/infrastructure/StatisticService.js @@ -16,8 +16,7 @@ * */ import Constants from '../config/constants'; -import globalConfig from '../config/globalConfig'; -import Axios from 'axios'; +import { NodeWatchService } from '../infrastructure'; class StatisticService { /** @@ -145,20 +144,12 @@ class StatisticService { } static getNodeCountSeries = async () => { - const data = await StatisticService.fetchFromStatisticsService('/timeSeries/nodeCount'); + const data = await NodeWatchService.getNodeCount(); const chartData = StatisticService.formatChartData(data, ['1', '2', '3', '4', '5', '6', '7', 'total']); return chartData.map(el => ({ ...el, name: Constants.RoleType[el.name] || el.name })); } - static fetchFromStatisticsService = async route => { - if (this.isUrlProvided()) - return (await Axios.get(globalConfig.endpoints.statisticsService + route)).data; - - else - throw Error('Statistics service endpoint is not provided'); - } - static formatChartData = (data, includeKeys) => { const aggreagatedData = {}; const isKeyIncluded = key => !includeKeys || includeKeys.includes(key); @@ -189,15 +180,6 @@ class StatisticService { return chartData; } - - static isUrlProvided () { - try { - new URL(globalConfig?.endpoints?.statisticsService); // eslint-disable-line no-new - return true; - } catch (e) { - return false; - } - } } export default StatisticService; diff --git a/src/infrastructure/http.js b/src/infrastructure/http.js index 3e2ce43bb..c99de2ce6 100644 --- a/src/infrastructure/http.js +++ b/src/infrastructure/http.js @@ -20,10 +20,6 @@ import accountLabels from '../config/accountLabels'; import globalConfig from '../config/globalConfig'; import { NamespaceService } from '../infrastructure'; import * as symbol from 'symbol-sdk'; -import { - Configuration, - NodeApi -} from 'symbol-statistics-service-typescript-fetch-client'; let NODE_URL; @@ -190,21 +186,4 @@ export default class http { static get transactionPaginationStreamer() { return new symbol.TransactionPaginationStreamer(this.createRepositoryFactory.createTransactionRepository()); } - - static statisticServiceRestClient() { - try { - const statisticsServiceUrl = globalConfig.endpoints.statisticsService; - - if (statisticsServiceUrl && statisticsServiceUrl.length) { - return new NodeApi(new Configuration({ - fetchApi: fetch, - basePath: statisticsServiceUrl - })); - } else { - throw Error('Statistics service endpoint is not provided'); - } - } catch (error) { - console.error(error); - } - } } diff --git a/src/store/node.js b/src/store/node.js index 09b5ae0d2..de81f0d04 100644 --- a/src/store/node.js +++ b/src/store/node.js @@ -63,8 +63,7 @@ export default { hostInfoManager: (state, getters) => ({ loading: getters.timeline?.loading || getters.info?.loading, - error: !StatisticService.isUrlProvided() || - getters.timeline?.error || + error: getters.timeline?.error || getters.info?.error }) }, diff --git a/src/store/statistic.js b/src/store/statistic.js index fd2298278..70a4df567 100644 --- a/src/store/statistic.js +++ b/src/store/statistic.js @@ -29,8 +29,8 @@ import { Order } from 'symbol-sdk'; const managers = [ new DataSet( - 'nodeHeightStats', - () => NodeService.getNodeHeightStats() + 'nodeHeightAndFinalizedHeightStats', + () => NodeService.getNodeHeightAndFinalizedHeightStats() ) ]; @@ -123,7 +123,7 @@ export default { async uninitialize ({ commit, dispatch, getters }) { const callback = async () => {}; - getters.nodeHeightStats?.uninitialize(); + getters.nodeHeightAndFinalizedHeightStats?.uninitialize(); await LOCK.uninitialize(callback, commit, dispatch, getters); }, @@ -134,7 +134,7 @@ export default { context.commit('setLoadingBlockTimeDifference', true); context.commit('setLoadingTransactionPerBlock', true); context.commit('setLoadingNodeCountSeries', true); - context.getters.nodeHeightStats.setStore(context).initialFetch(); + context.getters.nodeHeightAndFinalizedHeightStats.setStore(context).initialFetch(); context.commit('setError', false); try { diff --git a/src/views/Statistics.vue b/src/views/Statistics.vue index fa78acdfd..4b9d53fa4 100644 --- a/src/views/Statistics.vue +++ b/src/views/Statistics.vue @@ -33,12 +33,10 @@ - - - - - + + + @@ -50,7 +48,7 @@ import NetworkFeesWidget from '../components/widgets/NetworkFeesWidget.vue'; import NetworkRentalFeesWidget from '../components/widgets/NetworkRentalFeesWidget.vue'; import ChartBlockTimeDifference from '../components/widgets/ChartBlockTimeDifference.vue'; import ChartTransactionPerBlock from '../components/widgets/ChartTransactionPerBlock.vue'; -import NodeHeightStatsWidget from '../components/widgets/NodeHeightStatsWidget.vue'; +import NodeHeightAndFinalizedHeightStatsWidget from '../components/widgets/NodeHeightAndFinalizedHeightStatsWidget.vue'; import ChartNodeCount from '../components/widgets/ChartNodeCount.vue'; export default { @@ -60,7 +58,7 @@ export default { NetworkRentalFeesWidget, ChartBlockTimeDifference, ChartTransactionPerBlock, - NodeHeightStatsWidget, + NodeHeightAndFinalizedHeightStatsWidget, ChartNodeCount }, mounted () { From f6c44132bf6226ac184910519d1e89234f8fbc74 Mon Sep 17 00:00:00 2001 From: Anthony Law Date: Sat, 6 Sep 2025 18:05:15 +0800 Subject: [PATCH 3/3] [explorer] fix: statistic page bubble chart cut half Problem: Bubble chart circle cut in half, tooltips display duplicate data. Solution: Extended X and Y axis range, removed duplicate Z value. --- src/components/Chart.vue | 35 ++++++++++++++--- ...odeHeightAndFinalizedHeightStatsWidget.vue | 39 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/components/Chart.vue b/src/components/Chart.vue index ecb415975..4764ceaff 100644 --- a/src/components/Chart.vue +++ b/src/components/Chart.vue @@ -88,6 +88,16 @@ export default { intYaxis: { type: Boolean, default: false + }, + // Axis range configuration for bubble charts + xaxisRange: { + type: Array, + default: () => null + }, + + yaxisRange: { + type: Array, + default: () => null } }, @@ -114,7 +124,13 @@ export default { reset: '', customIcons: [] } - } + }, + ...(this.type === 'bubble' && { + zoom: { + enabled: false, + type: 'x' + }, + }) }, stroke: { show: true, @@ -160,7 +176,7 @@ export default { display: false }, xaxis: { - type: this.xaxisType, + type: this.type === 'bubble' ? 'numeric' : this.xaxisType, axisBorder: { show: false, color: '#0998a6' @@ -173,7 +189,11 @@ export default { : val; } } - : {} + : {}, + ...(this.xaxisRange && { + min: this.xaxisRange[0], + max: this.xaxisRange[1] + }) }, yaxis: { tooltip: { @@ -191,12 +211,17 @@ export default { : val; } } - : {} + : {}, + ...(this.yaxisRange && { + min: this.yaxisRange[0], + max: this.yaxisRange[1] + }) }, tooltip: { enabled: true, z: { - title: 'Count: ' + formatter: () => '', + title: '' }, }, legend: { diff --git a/src/components/widgets/NodeHeightAndFinalizedHeightStatsWidget.vue b/src/components/widgets/NodeHeightAndFinalizedHeightStatsWidget.vue index 48992cc2f..afa1be34b 100644 --- a/src/components/widgets/NodeHeightAndFinalizedHeightStatsWidget.vue +++ b/src/components/widgets/NodeHeightAndFinalizedHeightStatsWidget.vue @@ -12,6 +12,8 @@ :data="chartData" xaxisType="category" :height="400" + :xaxisRange="xaxisRange" + :yaxisRange="yaxisRange" /> @@ -57,6 +59,43 @@ export default { return this.data || []; }, + // Calculate axis ranges for bubble chart to ensure full circles + xaxisRange () { + if (!this.chartData || this.chartData.length === 0) return null; + + const allXValues = []; + this.chartData.forEach(series => { + series.data.forEach(point => { + allXValues.push(point.x); + }); + }); + + const minX = Math.min(...allXValues); + const maxX = Math.max(...allXValues); + const range = (maxX - minX) * 0.05; + + // Add padding to ensure bubbles don't get cut off + return [minX - range, maxX + range]; + }, + + yaxisRange () { + if (!this.chartData || this.chartData.length === 0) return null; + + const allYValues = []; + this.chartData.forEach(series => { + series.data.forEach(point => { + allYValues.push(point.y); + }); + }); + + const minY = Math.min(...allYValues); + const maxY = Math.max(...allYValues); + const range = (maxY - minY) * 0.5; + + // Add padding to ensure bubbles don't get cut off + return [Math.max(0, minY - range), maxY + range]; + }, + loading () { return this.manager.loading; },