diff --git a/apps/rest-api-app/src/main/java/bisq/rest_api/JaxRsApplication.java b/apps/rest-api-app/src/main/java/bisq/rest_api/JaxRsApplication.java index 933cbb28f9..e5af41ca6f 100644 --- a/apps/rest-api-app/src/main/java/bisq/rest_api/JaxRsApplication.java +++ b/apps/rest-api-app/src/main/java/bisq/rest_api/JaxRsApplication.java @@ -44,6 +44,7 @@ public JaxRsApplication(String[] args, Supplier appli public CompletableFuture initialize() { httpServer = JdkHttpServerFactory.createHttpServer(URI.create(BASE_URL), this); httpServer.createContext("/doc", new StaticFileHandler("/doc/v1/")); + httpServer.createContext("/node-monitor", new StaticFileHandler("/node-monitor/")); log.info("Server started at {}.", BASE_URL); return CompletableFuture.completedFuture(true); } diff --git a/apps/rest-api-app/src/main/java/bisq/rest_api/RestApiApplicationService.java b/apps/rest-api-app/src/main/java/bisq/rest_api/RestApiApplicationService.java index 9d174b2a32..fc24868d83 100644 --- a/apps/rest-api-app/src/main/java/bisq/rest_api/RestApiApplicationService.java +++ b/apps/rest-api-app/src/main/java/bisq/rest_api/RestApiApplicationService.java @@ -20,8 +20,12 @@ import bisq.account.AccountService; import bisq.bisq_easy.BisqEasyService; import bisq.bonded_roles.BondedRolesService; +import bisq.bonded_roles.bonded_role.AuthorizedBondedRole; +import bisq.bonded_roles.bonded_role.BondedRole; import bisq.chat.ChatService; import bisq.common.application.Service; +import bisq.common.network.Address; +import bisq.common.network.TransportType; import bisq.common.observable.Observable; import bisq.common.platform.OS; import bisq.common.util.CompletableFutureUtils; @@ -47,9 +51,13 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import static com.google.common.base.Preconditions.checkArgument; import static java.util.concurrent.CompletableFuture.supplyAsync; @@ -269,7 +277,9 @@ private void setState(State newState) { "New state %s must have a higher ordinal as the current state %s", newState, state.get()); state.set(newState); log.info("New state {}", newState); - } private Optional findSystemNotificationDelegate() { + } + + private Optional findSystemNotificationDelegate() { try { switch (OS.getOS()) { case LINUX: @@ -287,4 +297,30 @@ private void setState(State newState) { return Optional.empty(); } } + + public List getAddressList() { + + Set
bannedAddresses = bondedRolesService.getAuthorizedBondedRolesService().getBondedRoles().stream() + .filter(BondedRole::isBanned) + .map(BondedRole::getAuthorizedBondedRole) + .map(AuthorizedBondedRole::getAddressByTransportTypeMap) + .filter(Optional::isPresent) + .map(Optional::get) + .flatMap(map -> map.values().stream()) + .collect(Collectors.toSet()); + Map> seedAddressesByTransport = networkService.getSeedAddressesByTransportFromConfig(); + Set supportedTransportTypes = networkService.getSupportedTransportTypes(); + List addresslist = seedAddressesByTransport.entrySet().stream() + .filter(entry -> supportedTransportTypes.contains(entry.getKey())) + .flatMap(entry -> entry.getValue().stream()) + .filter(address -> !bannedAddresses.contains(address)) + .map(Address::toString) + .collect(Collectors.toList()); + + // Oracle Nodes + addresslist.add("kr4yvzlhwt5binpw7js2tsfqv6mjd4klmslmcxw3c5izsaqh5vvsp6ad.onion:36185"); + addresslist.add("s2yxxqvyofzud32mxliya3dihj5rdlowagkblqqtntxhi7cbdaufqkid.onion:54467"); + + return addresslist; + } } diff --git a/apps/rest-api-app/src/main/java/bisq/rest_api/dto/ReportDto.java b/apps/rest-api-app/src/main/java/bisq/rest_api/dto/ReportDto.java index 20125f5134..3dabb05492 100644 --- a/apps/rest-api-app/src/main/java/bisq/rest_api/dto/ReportDto.java +++ b/apps/rest-api-app/src/main/java/bisq/rest_api/dto/ReportDto.java @@ -25,12 +25,21 @@ @Schema(name = "Report") public final class ReportDto { private Report report; + private String errorMessage; public static ReportDto from(Report report) { ReportDto dto = new ReportDto(); dto.report = report; return dto; } -} + public static ReportDto fromError(String errorMessage) { + ReportDto dto = new ReportDto(); + dto.errorMessage = errorMessage; + return dto; + } + public boolean isSuccessful() { + return errorMessage == null; + } +} diff --git a/apps/rest-api-app/src/main/java/bisq/rest_api/endpoints/ReportApi.java b/apps/rest-api-app/src/main/java/bisq/rest_api/endpoints/ReportApi.java index ce7a20d686..ac033e8304 100644 --- a/apps/rest-api-app/src/main/java/bisq/rest_api/endpoints/ReportApi.java +++ b/apps/rest-api-app/src/main/java/bisq/rest_api/endpoints/ReportApi.java @@ -17,11 +17,10 @@ package bisq.rest_api.endpoints; +import bisq.common.network.Address; import bisq.common.util.CollectionUtil; import bisq.common.util.CompletableFutureUtils; import bisq.network.NetworkService; -import bisq.common.network.Address; -import bisq.network.p2p.services.reporting.Report; import bisq.rest_api.JaxRsApplication; import bisq.rest_api.RestApiApplicationService; import bisq.rest_api.dto.ReportDto; @@ -49,12 +48,31 @@ @Tag(name = "Report API") public class ReportApi { private final NetworkService networkService; + private final RestApiApplicationService applicationService; public ReportApi(@Context Application application) { - RestApiApplicationService applicationService = ((JaxRsApplication) application).getApplicationService().get(); + applicationService = ((JaxRsApplication) application).getApplicationService().get(); networkService = applicationService.getNetworkService(); } + @Operation(description = "Get a address list of seed and oracle nodes") + @ApiResponse(responseCode = "200", description = "the list of seed and oracle node addresses", + content = { + @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = ReportDto.class) + )} + ) + @GET + @Path("get-address-list") + public List getAddressList() { + try { + return applicationService.getAddressList(); + } catch (Exception e) { + throw new RuntimeException("Failed to get the node address list"); + } + } + @Operation(description = "Get report for given address") @ApiResponse(responseCode = "200", description = "the report for the given address", content = { @@ -65,14 +83,13 @@ public ReportApi(@Context Application application) { ) @GET @Path("get-report/{address}") - public ReportDto getReport(@Parameter(description = "address from which we request the report") @PathParam("address") String address) { - CompletableFuture future = networkService.requestReport(Address.fromFullAddress(address)); + public ReportDto getReport( + @Parameter(description = "address from which we request the report") + @PathParam("address") String address) { try { - Report report = future.get(); - log.info(report.toString()); - return ReportDto.from(report); + return fetchReportForAddress(address).join(); } catch (Exception e) { - throw new RuntimeException(e); + throw new RuntimeException("Failed to get report for address: " + address); } } @@ -86,16 +103,43 @@ public ReportDto getReport(@Parameter(description = "address from which we reque ) @GET @Path("get-reports/{addresses}") - public List getReports(@Parameter(description = "comma separated addresses from which we request the report") @PathParam("addresses") String addresses) { - List> futures = CollectionUtil.streamFromCsv(addresses) - .map(address -> networkService.requestReport(Address.fromFullAddress(address))) + public List getReports( + @Parameter(description = "comma separated addresses from which we request the report") + @PathParam("addresses") String addresses) { + + List addressList; + try { + addressList = CollectionUtil.streamFromCsv(addresses).toList(); + } catch (Exception e) { + throw new RuntimeException("Failed to parse addresses from CSV input: " + addresses); + } + + List> futures = addressList.stream() + .map(this::fetchReportForAddress) .toList(); + + CompletableFuture> allFutures = CompletableFutureUtils.allOf(futures); + + return allFutures.join(); + } + + private CompletableFuture fetchReportForAddress(String addressString) { try { - List reports = CompletableFutureUtils.allOf(futures).get(); - log.info(reports.toString()); - return reports.stream().map(ReportDto::from).toList(); + Address address = Address.fromFullAddress(addressString); + return networkService.requestReport(address) + .thenApply(report -> { + log.info("Report successfully created for address: {}", address); + return ReportDto.from(report); + }) + .exceptionally(e -> { + log.error("Failed to get report for address: {}. Nested: {}", address, e.getMessage()); + return ReportDto.fromError(e.getMessage()); + }); } catch (Exception e) { - throw new RuntimeException(e); + log.error("Error creating report for address: {}. Nested: {}", addressString, e.getMessage()); + return CompletableFuture.completedFuture( + ReportDto.fromError(e.getMessage()) + ); } } } diff --git a/apps/rest-api-app/src/main/resources/node-monitor/README.md b/apps/rest-api-app/src/main/resources/node-monitor/README.md new file mode 100644 index 0000000000..83e06de037 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/README.md @@ -0,0 +1,36 @@ + +# Bisq Node Monitor Application + +## Overview + +The **Bisq Node Monitor** is a web application designed to monitor Bisq nodes. +The application provides a user interface to input a list of hosts and ports, retrieves their status from an API, and displays the results in a structured and interactive format. + +## Project Structure + +The project is organized into different modules to ensure a clear separation of concerns and ease of maintenance. Each part of the application has a dedicated file or directory, as outlined below: + +``` +projekt-root/ +│ +├── index.html # Main HTML file that defines the application's structure +├── index.js # Main JavaScript file for initializing the application +├── README.md # Project README file with documentation +│ +├── js/ # JavaScript files organized by functionality +│ ├── constants.js # Global constants used throughout the application +│ ├── controllers/ # Application controllers +│ │ └── appController.js # Main application controller for handling user input and API calls +│ ├── services/ # Application services for data and storage management +│ │ ├── dataService.js # Service handling API requests and data retrieval +│ │ └── storageService.js # Service for handling local storage interactions +│ └── views/ # View components for different sections +│ ├── settingsView.js # View handling settings display and input +│ └── reportView.js # View managing the display of node reports +│ +└── styles/ # Directory containing CSS files for styling + ├── global.css # Global styles, typography, colors, and basic element styling + ├── page-layout.css # Layout and positioning of main areas, responsive styling + ├── reportView.css # Styling for the report view section + └── settingsView.css # Styling for the settings view section +``` diff --git a/apps/rest-api-app/src/main/resources/node-monitor/index.html b/apps/rest-api-app/src/main/resources/node-monitor/index.html new file mode 100644 index 0000000000..d5b82b191a --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/index.html @@ -0,0 +1,61 @@ + + + + + + + Bisq Node Monitor + + + + + + + +

Bisq Node Monitor

+ +
+ ☰ +
+ + + +
+ + +
+ + + +
+ +
+ + + + + + + + + + + + + diff --git a/apps/rest-api-app/src/main/resources/node-monitor/index.js b/apps/rest-api-app/src/main/resources/node-monitor/index.js new file mode 100644 index 0000000000..8d25d6f3c5 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/index.js @@ -0,0 +1,11 @@ +// index.js +// Namespace for the application +window.App = window.App || {}; + +document.addEventListener("DOMContentLoaded", () => { + const dataService = new App.Services.DataService(); + const storageService = new App.Services.StorageService(); + + const appController = new App.Controllers.AppController(dataService, storageService); + appController.initApp(); +}); diff --git a/apps/rest-api-app/src/main/resources/node-monitor/js/constants.js b/apps/rest-api-app/src/main/resources/node-monitor/js/constants.js new file mode 100644 index 0000000000..1363f0c144 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/js/constants.js @@ -0,0 +1,15 @@ +// js/constants.js +App.Constants = { + API_URL_GET_REPORT: 'http://localhost:8082/api/v1/report/get-report', + API_URL_GET_ADDRESS_LIST: 'http://localhost:8082/api/v1/report/get-address-list', + STATUS_ERROR: "Failed to fetch data", + STATUS_ENTER_HOSTS: "Please enter a Host:port list in the settings to start fetching data.", + PLACEHOLDER_HOST_LIST: "Host:port list, separated by commas or new lines.\n# Comments and empty lines are alowed.", + PLACEHOLDER_PORT_LIST: "Port list, for filtering hosts. Separated by commas or new lines.\n# Comments and empty lines are alowed.", + BUTTON_EXPAND_ALL: "Expand All Details", + BUTTON_COLLAPSE_ALL: "Collapse All Details", + BUTTON_EXPAND_DETAILS: "Expand Details", + BUTTON_COLLAPSE_DETAILS: "Collapse Details", + HOSTS_COOKIE_KEY: 'hosts', + PORTS_COOKIE_KEY: 'ports', +}; diff --git a/apps/rest-api-app/src/main/resources/node-monitor/js/controllers/appController.js b/apps/rest-api-app/src/main/resources/node-monitor/js/controllers/appController.js new file mode 100644 index 0000000000..b79394027b --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/js/controllers/appController.js @@ -0,0 +1,201 @@ +// js/controllers/appController.js +App.Controllers = App.Controllers || {}; + +App.Controllers.AppController = class { + constructor(dataService, storageService) { + this.dataService = dataService; + this.storageService = storageService; + this.reportView = new App.Views.ReportView(); + this.settingsView = new App.Views.SettingsView( + this.reportView, + this.storageService, + this.dataService, + this.onSettingsChanged.bind(this) + ); + } + + async initApp() { + try { + await this.#updateHostsAndLoadAllReports(); + this.#setInitialUIState(); + + this.#initReloadButton(); + this.#initToggleAllButton(); + } catch (error) { + console.error("Error on init app", error); + this.reportView.renderErrorMessage(error.message); + } + } + + async onSettingsChanged() { + try { + await this.#updateHostsAndLoadAllReports(); + } catch (error) { + this.reportView.renderErrorMessage(error.message); + } + } + + ////////////////////// + // PRIVATE METHODS + ////////////////////// + + #setInitialUIState() { + const hasHosts = this.hostList && this.hostList.length > 0; + this.#toggleReloadButton(hasHosts); + } + + #initReloadButton() { + document.getElementById("reloadButton").addEventListener("click", () => { + if (this.hostList.length > 0) { + this.#loadAllReports(); + } else { + this.reportView.renderErrorMessage(App.Constants.STATUS_ENTER_HOSTS); + } + }); + } + + #toggleReloadButton(show) { + this.#toggleButton(show, "reloadButton"); + } + + #initToggleAllButton() { + document.getElementById("toggleAllButton").addEventListener("click", () => { + const expand = document.getElementById("toggleAllButton").textContent === App.Constants.BUTTON_EXPAND_ALL; + const toggleButtons = document.querySelectorAll('.toggle-button'); + toggleButtons.forEach(button => { + try { + if (button.dataset.details) { + this.reportView.toggleDetailButton(button, expand); + } + } catch (error) { + console.warn("Skipping button due to invalid JSON in dataset.value:", button); + } + }); + + document.getElementById("toggleAllButton").textContent = expand ? + App.Constants.BUTTON_COLLAPSE_ALL : + App.Constants.BUTTON_EXPAND_ALL; + }); + } + + #toggleToggleAllButton(show) { + this.#toggleButton(show, "toggleAllButton"); + } + + #toggleButton(show, elementById) { + const button = document.getElementById(elementById); + button.style.display = show ? "block" : "none"; + } + + async #getHosts() { + let hosts = this.storageService.getHostsFromCookie(); + if (hosts.length === 0) { + hosts = await this.dataService.fetchHostList(); + if (hosts && hosts.length > 0) { + this.storageService.saveHosts(hosts.join('\n')); + this.reportView.clearMessage(); + } else { + throw new Error("No hosts available from server."); + } + } + return hosts; + } + + #cleanupObsoleteReports() { + const existingHostElements = document.querySelectorAll('.node-block'); + existingHostElements.forEach(element => { + const hostId = element.id.replace('report-', '').replace(/-/g, ':'); + const hostPort = parseInt(hostId.split(":")[1]); + + if (!this.hostList.includes(hostId) || (this.portList && !this.portList.includes(hostPort))) { + element.remove(); + console.log(`Removed obsolete or filtered host element for: ${hostId}`); + } + }); + } + + async #updateHostsAndLoadAllReports() { + try { + this.hostList = this.storageService.getHostsFromCookie(); + this.portList = this.storageService.getPortsFromCookie(); + + if (this.hostList.length === 0) { + this.hostList = await this.#getHosts(); + if (this.hostList.length === 0) { + this.reportView.renderErrorMessage(App.Constants.STATUS_ENTER_HOSTS); + this.#toggleReloadButton(false); + this.#toggleToggleAllButton(false); + return; + } + } else { + this.#toggleReloadButton(true); + this.#toggleToggleAllButton(true); + } + + this.#cleanupObsoleteReports(); + this.#loadAllReports(); + } catch (error) { + this.reportView.renderErrorMessage(error.message); + this.#toggleReloadButton(false); + this.#toggleToggleAllButton(false); + } + } + + #createOrUpdatePlaceholderForReport(host) { + const existingNodeBlock = document.getElementById(`report-${host.replace(/[:.]/g, '-')}`); + if (!existingNodeBlock) { + const nodeBlock = document.createElement('div'); + nodeBlock.classList.add('node-block'); + nodeBlock.id = `report-${host.replace(/[:.]/g, '-')}`; + + const header = document.createElement('div'); + header.classList.add('node-header'); + + const statusCircle = document.createElement('span'); + statusCircle.classList.add('status-circle', 'status-loading'); + header.appendChild(statusCircle); + + const hostText = document.createElement('span'); + hostText.textContent = host; + header.appendChild(hostText); + + nodeBlock.appendChild(header); + document.getElementById("reportContainer").appendChild(nodeBlock); + } else { + const statusCircle = existingNodeBlock.querySelector('.status-circle'); + statusCircle.className = 'status-circle status-loading'; + } + } + + async #loadSingleReport(host) { + try { + const { data } = await this.dataService.fetchReportData(host); + this.reportView.renderSingleReport(data, host); + } catch (error) { + this.reportView.renderErrorMessage(error.message); + } + } + + #loadAllReports() { + console.log("Loading reports for hosts:", this.hostList); + console.log("Allowed Ports:", this.portList); + + if (this.hostList.length > 0) { + this.#toggleToggleAllButton(true); + this.hostList.forEach(host => { + const hostPort = parseInt(host.split(":")[1]); + console.log("Checking host:", host, "with extracted port:", hostPort); + + if (!this.portList || this.portList.length === 0 || this.portList.includes(hostPort)) { + console.log("Fetching report for host with port:", host); + this.#createOrUpdatePlaceholderForReport(host); + this.#loadSingleReport(host); + } else { + console.log("Skipping host as port not in filter list:", host); + } + }); + } else { + this.#toggleToggleAllButton(false); + } + } +}; diff --git a/apps/rest-api-app/src/main/resources/node-monitor/js/services/dataService.js b/apps/rest-api-app/src/main/resources/node-monitor/js/services/dataService.js new file mode 100644 index 0000000000..0eae68ed0d --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/js/services/dataService.js @@ -0,0 +1,60 @@ +// js/services/dataService.js +App.Services = App.Services || {}; + +App.Services.DataService = class { + async fetchReportData(host) { + const url = `${App.Constants.API_URL_GET_REPORT}/${host}`; + try { + const response = await fetch(url); + if (!response.ok) { + const statusText = response.statusText || `Status code: ${response.status}`; + throw new Error(`Error fetching report for ${host}: ${statusText}`); + } + const contentType = response.headers.get("Content-Type") || ""; + if (!contentType.startsWith("application/json")) { + const errorMessage = await response.text(); + throw new Error(`Unexpected content type for ${host}: ${errorMessage}`); + } + const data = await response.json(); + const filteredData = this.#filterReportData(data); + return { success: true, data: filteredData }; + } catch (error) { + throw new Error(`Network error: Unable to fetch report for ${host}: ${error.message}`); + } + } + + async fetchHostList() { + try { + const response = await fetch(App.Constants.API_URL_GET_ADDRESS_LIST); + if (!response.ok) { + const statusText = response.statusText || `Status code: ${response.status}`; + throw new Error(`Server error while fetching host list: ${statusText}`); + } + const contentType = response.headers.get("Content-Type") || ""; + if (!contentType.startsWith("application/json")) { + const errorMessage = await response.text(); + throw new Error(`Unexpected content type for ${host}: ${errorMessage}`); + } + const data = await response.json(); + if (!data || !Array.isArray(data)) { + throw new Error("Invalid data format: expected an array."); + } + return data; + } catch (error) { + throw new Error("Network error: Unable to fetch host list: " + error.message); + } + } + + ////////////////////// + // PRIVATE METHODS + ////////////////////// + + #filterReportData(data) { + if (!data || typeof data !== 'object' || !data.report || typeof data.report !== 'object') { + return data; + } + const { version = null, serializedSize = null, excludedFields = null, ...filteredReport } = data.report; + return { ...data, report: filteredReport }; + + } +}; diff --git a/apps/rest-api-app/src/main/resources/node-monitor/js/services/storageService.js b/apps/rest-api-app/src/main/resources/node-monitor/js/services/storageService.js new file mode 100644 index 0000000000..02bf37dad0 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/js/services/storageService.js @@ -0,0 +1,137 @@ +// js/services/storageService.js +App.Services = App.Services || {}; + +App.Services.StorageService = class { + constructor() { + this.hostsKey = App.Constants.HOSTS_COOKIE_KEY; + this.portsKey = App.Constants.PORTS_COOKIE_KEY; + } + + saveHostsAndPorts(hostsInput, portsInput) { + try { + const hostList = this.#parseHostListInput(hostsInput); + const portList = this.#parsePortListInput(portsInput); + + this.#setHostsCookie(hostsInput); + this.#setPortsCookie(portsInput); + } catch (error) { + throw new Error("Host and port lists not stored: " + error.message); + } + } + + saveHosts(hostsInput) { + try { + const hostList = this.#parseHostListInput(hostsInput); + + this.#setHostsCookie(hostsInput); + } catch (error) { + throw new Error("Host list not stored: " + error.message); + } + } + + getHostsFromCookie() { + const hostsText = this.getHostsCookie(); + const uniqueHosts = new Set(); + + hostsText.split(/\r?\n/).forEach(line => { + line = line.trim(); + if (line && !line.startsWith('#')) { + line.split(',').forEach(host => { + host = host.trim(); + if (host) uniqueHosts.add(host); + }); + } + }); + + return Array.from(uniqueHosts); + } + + getPortsFromCookie() { + const portsText = this.getPortsCookie(); + const uniquePorts = new Set(); + + portsText.split(/\r?\n/).forEach(line => { + line = line.trim(); + if (line && !line.startsWith('#')) { + line.split(',').forEach(port => { + port = port.trim(); + if (port && !isNaN(port)) uniquePorts.add(Number(port)); + }); + } + }); + + return Array.from(uniquePorts); + } + + getHostsCookie() { + return this.#getCookie(this.hostsKey) || ""; + } + + getPortsCookie() { + return this.#getCookie(this.portsKey) || ""; + } + + ////////////////////// + // PRIVATE METHODS + ////////////////////// + + #getCookie(name) { + const decodedCookie = decodeURIComponent(document.cookie); + const cookiesArray = decodedCookie.split(';'); + for (let cookie of cookiesArray) { + cookie = cookie.trim(); + if (cookie.startsWith(name + "=")) { + return cookie.substring(name.length + 1); + } + } + return null; + } + + #parseHostListInput(input) { + return input + .split(/\r?\n|,/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + .map(item => { + if (!this.#isValidHostFormat(item)) { + throw new Error(`Invalid host entry detected: "${item}"`); + } + return item; + }); + }; + + #isValidHostFormat = (host) => { + const hostPattern = /^[a-zA-Z0-9.-]+:\d+$/; + return hostPattern.test(host); + }; + + #parsePortListInput(input) { + return input + .split(/\r?\n|,/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + .map(item => { + const port = Number(item); + if (isNaN(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid or out-of-range port detected: "${item}"`); + } + return port; + }); + }; + + #setCookie(key, text) { + const date = new Date(); + date.setTime(date.getTime() + (365 * 24 * 60 * 60 * 1000)); + const expires = "expires=" + date.toUTCString(); + document.cookie = `${key}=${encodeURIComponent(text)}; ${expires}; path=/; SameSite=None; Secure`; + } + + #setHostsCookie(hostsText) { + this.#setCookie(this.hostsKey, hostsText); + } + + #setPortsCookie(portsText) { + this.#setCookie(this.portsKey, portsText); + } +}; + diff --git a/apps/rest-api-app/src/main/resources/node-monitor/js/views/reportView.js b/apps/rest-api-app/src/main/resources/node-monitor/js/views/reportView.js new file mode 100644 index 0000000000..cfbd21ce31 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/js/views/reportView.js @@ -0,0 +1,142 @@ +// js/views/reportView.js +App.Views = App.Views || {}; + +App.Views.ReportView = class { + constructor() {} + + renderInfoMessage(message, dauer) { + this.#renderMessage(message, "green", dauer); + } + + renderWarnMessage(message, dauer) { + this.#renderMessage(message, "orange", dauer); + } + + renderErrorMessage(message, dauer) { + this.#renderMessage(message, "red", dauer); + } + + clearMessage() { + const statusMessage = document.getElementById("statusMessage"); + statusMessage.style.display = "none"; + } + + renderSingleReport(data, host) { + const nodeBlock = document.getElementById(`report-${host.replace(/[:.]/g, '-')}`); + nodeBlock.innerHTML = ''; + + const header = document.createElement('div'); + header.classList.add('node-header'); + + const statusCircle = document.createElement('span'); + statusCircle.classList.add('status-circle'); + statusCircle.classList.add(data.successful ? 'status-ok' : 'status-error'); + header.appendChild(statusCircle); + + const hostText = document.createElement('span'); + hostText.textContent = host; + header.appendChild(hostText); + + nodeBlock.appendChild(header); + + if (data.successful) { + const mainTable = this.#createTable(data.report, "Report", 0); + nodeBlock.appendChild(mainTable); + } else { + const errorDiv = document.createElement('div'); + errorDiv.classList.add('error'); + errorDiv.textContent = data.errorMessage || App.Constants.STATUS_ERROR; + nodeBlock.appendChild(errorDiv); + } + } + + toggleDetailButton(button, expand) { + const details = JSON.parse(button.dataset.details); + const key = button.dataset.key; + const depth = parseInt(button.dataset.depth); + + if (!button.classList.contains('expanded')) { + const nestedTable = this.#createTable(details, key, depth + 1); + nestedTable.style.display = 'none'; + button.tableDiv.appendChild(nestedTable); + button.classList.add('expanded'); + button.nestedTable = nestedTable; + } + const isVisible = expand !== undefined ? expand : button.nestedTable.style.display === 'none'; + button.nestedTable.style.display = isVisible ? 'block' : 'none'; + button.textContent = isVisible ? BUTTON_COLLAPSE_DETAILS : BUTTON_EXPAND_DETAILS; + } + + ////////////////////// + // PRIVATE METHODS + ////////////////////// + + #renderMessage(message, color = "grey", dauer) { + const statusMessage = document.getElementById("statusMessage"); + statusMessage.textContent = message; + statusMessage.style.color = color; + statusMessage.style.display = "block"; + + if (dauer) { + setTimeout(() => { + this.clearMessage(); + }, dauer * 1000); + } + } + + #formatColumnName(name) { + return name.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()); + }; + + #createTable(data, title, depth = 0) { + const tableDiv = document.createElement('div'); + tableDiv.classList.add('table-container'); + tableDiv.style.marginLeft = `${depth * 20}px`; + + const tableTitle = document.createElement('div'); + tableTitle.classList.add('sub-structure-title'); + tableTitle.textContent = title; + tableDiv.appendChild(tableTitle); + + const table = document.createElement('table'); + const headerRow = document.createElement('tr'); + const valueRow = document.createElement('tr'); + + const keys = Object.keys(data); + const simpleKeys = keys.filter(key => typeof data[key] !== 'object' || data[key] === null); + const complexKeys = keys.filter(key => typeof data[key] === 'object' && data[key] !== null); + const sortedKeys = [...simpleKeys, ...complexKeys]; + + sortedKeys.forEach(key => { + const th = document.createElement('th'); + th.textContent = this.#formatColumnName(key); + th.title = key; + headerRow.appendChild(th); + + const td = document.createElement('td'); + const details = data[key]; + + if (complexKeys.includes(key)) { + const button = document.createElement('button'); + button.classList.add('button', 'button--blue', 'toggle-button'); + button.textContent = App.Constants.BUTTON_EXPAND_DETAILS; + button.dataset.key = key; + button.dataset.details = JSON.stringify(details); + button.dataset.depth = depth; + button.tableDiv = tableDiv; + button.onclick = () => this.toggleDetailButton(button); + td.appendChild(button); + } else { + td.textContent = details !== undefined ? details : "N/A"; + } + valueRow.appendChild(td); + }); + + table.appendChild(headerRow); + table.appendChild(valueRow); + tableDiv.appendChild(table); + + return tableDiv; + } +}; + diff --git a/apps/rest-api-app/src/main/resources/node-monitor/js/views/settingsView.js b/apps/rest-api-app/src/main/resources/node-monitor/js/views/settingsView.js new file mode 100644 index 0000000000..11c8bf999e --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/js/views/settingsView.js @@ -0,0 +1,99 @@ +// js/views/settingsView.js +App.Views = App.Views || {}; + +App.Views.SettingsView = class { + constructor(reportView, storageService, dataService, onSettingsChangedCallback) { + this.reportView = reportView; + this.storageService = storageService; + this.dataService = dataService; + this.onSettingsChanged = onSettingsChangedCallback; + this.#initialize(); + } + + toggleSettingsMenu() { + const settingsPanel = document.getElementById("settingsPanel"); + const isCurrentlyHidden = settingsPanel.style.display === "none"; + settingsPanel.style.display = isCurrentlyHidden ? "block" : "none"; + if (isCurrentlyHidden) { + this.#initializeHostsTextAndPortsText(); + } + } + + ////////////////////// + // PRIVATE METHODS + ////////////////////// + + #initialize() { + this.#initializeHamburgerButton(); + this.#initializeFetchRemoteListButton(); + this.#initializeSaveConfigButton(); + this.#initializePlaceHolder(); + this.#initializeHostsTextAndPortsText(); + } + + #initializePlaceHolder() { + document.getElementById("hostListInput").placeholder = App.Constants.PLACEHOLDER_HOST_LIST; + document.getElementById("portListInput").placeholder = App.Constants.PLACEHOLDER_PORT_LIST; + } + + #initializeHostsTextAndPortsText() { + const hostsText = this.storageService.getHostsCookie(); + const portsText = this.storageService.getPortsCookie(); + document.getElementById("hostListInput").value = hostsText; + document.getElementById("portListInput").value = portsText; + } + + #initializeHamburgerButton() { + document.getElementById("hamburgerButton").addEventListener("click", () => { + this.toggleSettingsMenu(); + }); + } + + #initializeFetchRemoteListButton() { + const fetchRemoteListButton = document.getElementById("fetchRemoteListButton"); + fetchRemoteListButton.classList.add("button", "button--orange"); + fetchRemoteListButton.addEventListener("click", async () => { + await this.#fetchAndDisplayRemoteHostList(); + }); + } + + #initializeSaveConfigButton() { + const saveConfigButton = document.getElementById("saveConfigButton"); + saveConfigButton.classList.add("button", "button--green"); + saveConfigButton.addEventListener("click", () => { + this.#saveCurrentConfiguration(); + }); + } + + async #fetchAndDisplayRemoteHostList() { + this.reportView.clearMessage(); + try { + const remoteHosts = await this.dataService.fetchHostList(); + if (remoteHosts && Array.isArray(remoteHosts)) { + const newValue = remoteHosts.join('\n'); + if (newValue.length > 0) { + document.getElementById("hostListInput").value = newValue; + } else { + this.reportView.renderErrorMessage("Received host list is empty."); + } + } else { + this.reportView.renderErrorMessage("Failed to fetch remote host list."); + } + } catch (error) { + this.reportView.renderErrorMessage(error.message); + } + } + + #saveCurrentConfiguration() { + const hostsInput = document.getElementById("hostListInput").value; + const portsInput = document.getElementById("portListInput").value; + try { + this.storageService.saveHostsAndPorts(hostsInput, portsInput); + this.reportView.renderInfoMessage("Saved successfully", 1); + this.toggleSettingsMenu(); + Promise.resolve().then(() => this.onSettingsChanged()); + } catch (error) { + this.reportView.renderErrorMessage(error.message); + } + } +}; \ No newline at end of file diff --git a/apps/rest-api-app/src/main/resources/node-monitor/styles/global.css b/apps/rest-api-app/src/main/resources/node-monitor/styles/global.css new file mode 100644 index 0000000000..736e847057 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/styles/global.css @@ -0,0 +1,94 @@ +/* styles/global.css */ +/* Basic reset and global styling */ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + box-sizing: border-box; +} + +h2 { + text-align: center; + color: #333; + margin-top: 20px; +} + +/* General error styling */ +.error { + color: red; + font-weight: bold; +} + +/* Status indicator circles */ +.status-circle { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + margin-right: 10px; +} + +/* Colors for status */ +.status-loading { + background-color: yellow; +} + +.status-ok { + background-color: green; +} + +.status-error { + background-color: red; +} + +/* Basic button styling to be extended in specific views */ +.button { + padding: 5px 10px; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.85em; + display: block; + max-width: 200px; + text-align: center; + background-color: #007bff; + margin-top: 5px; +} + +.button--green { + background-color: #28a745; +} + +.button--green:hover { + background-color: #218838; +} + +.button--green:active { + background-color: #196f31; +} + +.button--blue { + background-color: #007bff; +} + +.button--blue:hover { + background-color: #0056b3; +} + +.button--blue:active { + background-color: #004494; +} + +/* Fetch Remote List Button - using orange for warning */ +.button--orange { + background-color: #ff8c00; /* Orange as a warning color */ +} + +.button--orange:hover { + background-color: #e07b00; +} + +.button--orange:active { + background-color: #cc6f00; +} diff --git a/apps/rest-api-app/src/main/resources/node-monitor/styles/page-layout.css b/apps/rest-api-app/src/main/resources/node-monitor/styles/page-layout.css new file mode 100644 index 0000000000..4bae378da9 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/styles/page-layout.css @@ -0,0 +1,19 @@ +/* styles/page-layout.css */ +.button-container { + display: flex; + align-items: center; + margin: 10px 20px; + gap: 10px; +} + +@media (max-width: 768px) { + h2 { + font-size: 1.5em; + } +} + +@media (max-width: 480px) { + h2 { + font-size: 1.2em; + } +} diff --git a/apps/rest-api-app/src/main/resources/node-monitor/styles/reportView.css b/apps/rest-api-app/src/main/resources/node-monitor/styles/reportView.css new file mode 100644 index 0000000000..210dcd9d2c --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/styles/reportView.css @@ -0,0 +1,66 @@ +/* styles/reportView.css */ +/* Node block container styling */ +.node-block { + border: 1px solid #ddd; + padding: 15px; + margin: 10px 20px; + border-radius: 8px; + background-color: #f9f9f9; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Adds slight shadow for depth */ +} + +/* Header styling for each node */ +.node-header { + display: flex; + align-items: center; + margin-bottom: 10px; + flex-wrap: wrap; /* Allows wrapping on smaller screens */ +} + +/* Table styling for report details */ +.table-container { + margin-top: 10px; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 5px; +} + +th, td { + padding: 8px; + border: 1px solid #ddd; + text-align: left; +} + +/* Header styling for table columns */ +th { + font-size: 0.85em; + background-color: #f2f2f2; + text-transform: capitalize; + cursor: help; + max-width: 120px; + white-space: normal; + word-wrap: break-word; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .node-block { + padding: 10px; + margin: 8px 10px; + } + + th, td { + font-size: 0.75em; + padding: 6px; + } +} + +@media (max-width: 480px) { + .node-header { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/apps/rest-api-app/src/main/resources/node-monitor/styles/settingsView.css b/apps/rest-api-app/src/main/resources/node-monitor/styles/settingsView.css new file mode 100644 index 0000000000..e57a598bfb --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/styles/settingsView.css @@ -0,0 +1,51 @@ +/* styles/settingsView.css */ +/* Settings panel container */ +#settingsPanel { + padding: 15px; + background-color: #f9f9f9; + border: 1px solid #ddd; + border-radius: 8px; + width: 90%; + max-width: 650px; + margin: 20px auto; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +#settingsPanel h3 { + margin-top: 0; + font-size: 1.2em; + color: #333; +} + +#settingsPanel label { + font-weight: bold; + margin-top: 10px; + display: block; +} + +/* Text area styling for settings panel */ +#settingsPanel textarea { + padding: 8px; + font-size: 1.1em; + border: 1px solid #ccc; + border-radius: 4px; + width: 100%; + max-width: 100%; + resize: vertical; + box-sizing: border-box; +} + +/* Specific styling for hamburger menu */ +.hamburger-menu { + font-size: 24px; + cursor: pointer; + position: absolute; + top: 10px; + right: 10px; + user-select: none; + color: #333; +} + +.hamburger-menu:hover { + color: #555; +}