From 686aecbf16946376ec1266f5ebde371b03e41ef2 Mon Sep 17 00:00:00 2001 From: bsc7 <118875465+bsc7@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:43:14 +0100 Subject: [PATCH] Simple HTML frontend for Bisq 2 monitor Accessible via http://localhost:8082/node-monitor/index.html The hostlist and portlist-filter can be set in settings as a list, separated by newlines or commas, and will be saved in a cookie. --- .../java/bisq/rest_api/JaxRsApplication.java | 1 + .../java/bisq/rest_api/dto/ReportDto.java | 11 +- .../bisq/rest_api/endpoints/ReportApi.java | 55 ++++-- .../src/main/resources/node-monitor/app.js | 65 ++++++ .../resources/node-monitor/eventHandlers.js | 41 ++++ .../main/resources/node-monitor/helpers.js | 82 ++++++++ .../main/resources/node-monitor/index.html | 47 +++++ .../main/resources/node-monitor/styles.css | 187 ++++++++++++++++++ .../resources/node-monitor/toggleFunctions.js | 33 ++++ .../src/main/resources/node-monitor/ui.js | 100 ++++++++++ 10 files changed, 607 insertions(+), 15 deletions(-) create mode 100644 apps/rest-api-app/src/main/resources/node-monitor/app.js create mode 100644 apps/rest-api-app/src/main/resources/node-monitor/eventHandlers.js create mode 100644 apps/rest-api-app/src/main/resources/node-monitor/helpers.js create mode 100644 apps/rest-api-app/src/main/resources/node-monitor/index.html create mode 100644 apps/rest-api-app/src/main/resources/node-monitor/styles.css create mode 100644 apps/rest-api-app/src/main/resources/node-monitor/toggleFunctions.js create mode 100644 apps/rest-api-app/src/main/resources/node-monitor/ui.js 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/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..51e18bdaa0 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,10 +17,9 @@ 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; @@ -66,13 +65,10 @@ 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)); 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 +82,47 @@ 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 = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + + return allFutures.thenApply(v -> futures.stream() + .map(CompletableFuture::join) + .toList()) + .join(); + } + + private CompletableFuture fetchReportForAddress(String address) { try { - List reports = CompletableFutureUtils.allOf(futures).get(); - log.info(reports.toString()); - return reports.stream().map(ReportDto::from).toList(); + Address addr = Address.fromFullAddress(address); + return networkService.requestReport(addr) + .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: {}", address, e.getMessage()); + return CompletableFuture.completedFuture( + ReportDto.fromError(e.getMessage()) + ); } } } diff --git a/apps/rest-api-app/src/main/resources/node-monitor/app.js b/apps/rest-api-app/src/main/resources/node-monitor/app.js new file mode 100644 index 0000000000..bf6d0aadf2 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/app.js @@ -0,0 +1,65 @@ +// Core app logic + +async function loadDataForHost(host, totalHosts, completedCallback) { + try { + const reportsUrl = `http://localhost:8082/api/v1/report/get-report/${host}`; + const reportsResponse = await fetch(reportsUrl); + + const contentType = reportsResponse.headers.get("Content-Type") || ""; + + let reportData; + if (contentType.startsWith("application/json")) { + try { + reportData = await reportsResponse.json(); + } catch (parseError) { + const responseText = await reportsResponse.text(); + reportData = { successful: false, errorMessage: responseText || "Invalid JSON response" }; + } + } else { + const responseText = await reportsResponse.text(); + reportData = { successful: false, errorMessage: responseText }; + } + + displaySingleReport(reportData, host); + + } catch (error) { + displaySingleReport({ successful: false, errorMessage: "Failed to fetch report" }, host); + } finally { + completedCallback(); + } +} + +function updateStatusMessage(message, color = "grey") { + const statusMessage = document.getElementById("statusMessage"); + statusMessage.textContent = message; + statusMessage.style.color = color; + statusMessage.style.display = "block"; +} + +function loadData() { + const hostList = getFilteredHostList(); + + if (hostList.length === 0) { + updateStatusMessage("Please enter a Host:port list in the settings to start fetching data.", "red"); + return; + } + + updateStatusMessage("Loading...", "grey"); + + const reportContainer = document.getElementById("reportContainer"); + reportContainer.innerHTML = ""; + + let completedRequests = 0; + const totalHosts = hostList.length; + + hostList.forEach(host => { + createPlaceholderForHost(host); + loadDataForHost(host, totalHosts, () => { + completedRequests += 1; + if (completedRequests === totalHosts) { + document.getElementById("statusMessage").style.display = "none"; + } + }); + }); +} + diff --git a/apps/rest-api-app/src/main/resources/node-monitor/eventHandlers.js b/apps/rest-api-app/src/main/resources/node-monitor/eventHandlers.js new file mode 100644 index 0000000000..c97729148f --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/eventHandlers.js @@ -0,0 +1,41 @@ +// Event handlers and initialization + +// Automatic loading at startup if a host list is available +window.onload = () => { + const savedHosts = getHostsCookie(); + const savedPorts = getPortsCookie(); + + if (savedHosts) { + document.getElementById("hostListInput").value = savedHosts; + } + if (savedPorts && savedPorts.length > 0) { + document.getElementById("portListInput").value = savedPorts.join('\n'); + } + + if (savedHosts.trim().length > 0) { + loadData(); + } else { + updateStatusMessage("Please enter a Host:port list in the settings to start fetching data.", "red"); + } +}; + +// Toggle Settings Panel +document.getElementById("hamburgerButton").addEventListener("click", toggleSettingsMenu); + +// Save Configuration and Close Settings Panel +document.getElementById("saveConfigButton").addEventListener("click", function() { + const rawHostListText = document.getElementById("hostListInput").value; + const portList = parseHostListInput(document.getElementById("portListInput").value).map(Number); + + setHostsCookie(rawHostListText); + setPortsCookie(portList); + + loadData(); + toggleSettingsMenu(); +}); + +// Toggle all details (global expand/collapse button) +document.getElementById("toggleAllButton").addEventListener('click', function() { + const expand = this.textContent === "Expand All Details"; + toggleAllDetails(expand); +}); diff --git a/apps/rest-api-app/src/main/resources/node-monitor/helpers.js b/apps/rest-api-app/src/main/resources/node-monitor/helpers.js new file mode 100644 index 0000000000..825193bc6c --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/helpers.js @@ -0,0 +1,82 @@ +// Helper functions + +function setHostsCookie(rawHostListText) { + const date = new Date(); + date.setTime(date.getTime() + (365 * 24 * 60 * 60 * 1000)); + const expires = "expires=" + date.toUTCString(); + document.cookie = `hosts=${encodeURIComponent(rawHostListText)}; ${expires}; path=/; SameSite=None; Secure`; +} + +function getHostsCookie() { + const name = "hosts="; + const decodedCookie = decodeURIComponent(document.cookie); + const cookieArray = decodedCookie.split(';'); + + for (let i = 0; i < cookieArray.length; i++) { + let cookie = cookieArray[i].trim(); + if (cookie.indexOf(name) === 0) { + return cookie.substring(name.length); + } + } + return ""; +} + +function setPortsCookie(portList) { + const date = new Date(); + date.setTime(date.getTime() + (365 * 24 * 60 * 60 * 1000)); + const expires = "expires=" + date.toUTCString(); + document.cookie = `ports=${encodeURIComponent(JSON.stringify(portList))}; ${expires}; path=/; SameSite=None; Secure`; +} + +function getPortsCookie() { + const name = "ports="; + const decodedCookie = decodeURIComponent(document.cookie); + const cookiesArray = decodedCookie.split(';'); + for (let cookie of cookiesArray) { + cookie = cookie.trim(); + if (cookie.indexOf(name) === 0) { + return JSON.parse(cookie.substring(name.length)); + } + } + return []; +} + +function formatColumnName(name) { + return name.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()); +} + +function parseHostListInput(input) { + const uniqueHosts = new Set(); + input.split(/\r?\n/) + .forEach(line => { + line = line.trim(); + + if (line.startsWith('#') || line === '') return; + + line.split(',') + .map(host => host.trim()) + .filter(host => host) + .forEach(host => uniqueHosts.add(host)); + }); + return Array.from(uniqueHosts); +} + +function isValidHostFormat(host) { + const hostPattern = /^[a-zA-Z0-9.-]+:\d+$/; + return hostPattern.test(host); +} + +function getFilteredHostList() { + const hostList = parseHostListInput(document.getElementById("hostListInput").value); + const portList = parseHostListInput(document.getElementById("portListInput").value).map(Number); + + if (portList.length === 0) { + return hostList; + } + + return hostList.filter(host => { + const [, port] = host.split(':'); + return portList.includes(Number(port)); + }); +} + 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..4800c078e2 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/index.html @@ -0,0 +1,47 @@ + + + + + + Bisq Node Monitor + + + + +

Bisq Node Monitor

+ +
+ ☰ +
+ + + + + + + +
+ +
+ + + + + + + + + diff --git a/apps/rest-api-app/src/main/resources/node-monitor/styles.css b/apps/rest-api-app/src/main/resources/node-monitor/styles.css new file mode 100644 index 0000000000..aa15a66538 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/styles.css @@ -0,0 +1,187 @@ +/* Basic styling */ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + box-sizing: border-box; +} + +h2 { + text-align: center; + color: #333; + margin-top: 20px; +} + +.node-block { + border: 1px solid #ddd; + padding: 15px; + margin: 10px 20px; + border-radius: 8px; + background-color: #f9f9f9; +} + +.node-header { + display: flex; + align-items: center; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.status-circle { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + margin-right: 10px; +} + +.status-loading { + background-color: yellow; +} + +.status-ok { + background-color: green; +} + +.status-error { + background-color: red; +} + +.table-container { + margin-top: 10px; +} + +/* Table styling */ +.sub-structure-title { + font-weight: bold; + margin-top: 10px; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 5px; +} + +th, td { + padding: 8px; + border: 1px solid #ddd; + text-align: left; +} + +th { + font-size: 0.85em; + background-color: #f2f2f2; + text-transform: capitalize; + cursor: help; + max-width: 120px; + white-space: normal; + word-wrap: break-word; +} + +.error { + color: red; + font-weight: bold; +} + +/* Styling for the global "Expand All" button */ +.toggle-all-button { + padding: 8px 12px; + margin: 10px auto; + display: block; + background-color: #28a745; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; +} + +.toggle-all-button:hover { + background-color: #218838; +} + +.toggle-button { + padding: 5px 10px; + margin-top: 5px; + background-color: #007bff; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.85em; +} + +.toggle-button:hover { + background-color: #0056b3; +} + +.toggle-button:focus, +.toggle-all-button:focus { + outline: none; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + h2 { + font-size: 1.5em; + } + .node-block { + padding: 10px; + margin: 8px 10px; + } + th, td { + font-size: 0.75em; + padding: 6px; + } + th { + max-width: 80px; + } + .sub-structure-title { + font-size: 1em; + } +} + +@media (max-width: 480px) { + h2 { + font-size: 1.2em; + } + .node-header { + flex-direction: column; + align-items: flex-start; + } + th, td { + font-size: 0.7em; + padding: 5px; + } + th { + max-width: 60px; + } + .status-circle { + width: 10px; + height: 10px; + } +} + +.hamburger-menu { + font-size: 24px; + cursor: pointer; + position: absolute; + top: 10px; + right: 10px; + user-select: none; +} + +#settingsPanel { + padding: 15px; + background-color: #f9f9f9; + border: 1px solid #ddd; + border-radius: 8px; + width: 80%; + max-width: 500px; + margin: 20px auto; +} + +#settingsPanel textarea { + width: 125%; +} diff --git a/apps/rest-api-app/src/main/resources/node-monitor/toggleFunctions.js b/apps/rest-api-app/src/main/resources/node-monitor/toggleFunctions.js new file mode 100644 index 0000000000..84f18dbc39 --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/toggleFunctions.js @@ -0,0 +1,33 @@ +// Toggle functions for expanding/collapsing details + +function toggleDetailButton(button, expand) { + const value = JSON.parse(button.dataset.value); + const key = button.dataset.key; + const depth = parseInt(button.dataset.depth); + + if (!button.classList.contains('expanded')) { + const nestedTable = createTable(value, 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 ? "Collapse Details" : "Expand Details"; +} + +function toggleAllDetails(expand) { + const toggleButtons = Array.from(document.querySelectorAll('.toggle-button')).filter(button => button.dataset.value); + toggleButtons.forEach(button => { + try { + const value = JSON.parse(button.dataset.value); + if (value) { + toggleDetailButton(button, expand); + } + } catch (e) { + console.warn("Skipping button due to invalid JSON in dataset.value:", button); + } + }); + document.getElementById('toggleAllButton').textContent = expand ? "Collapse All Details" : "Expand All Details"; +} diff --git a/apps/rest-api-app/src/main/resources/node-monitor/ui.js b/apps/rest-api-app/src/main/resources/node-monitor/ui.js new file mode 100644 index 0000000000..6c1fe78c9a --- /dev/null +++ b/apps/rest-api-app/src/main/resources/node-monitor/ui.js @@ -0,0 +1,100 @@ +// UI related functions + +function toggleSettingsMenu() { + const settingsPanel = document.getElementById("settingsPanel"); + settingsPanel.style.display = settingsPanel.style.display === "none" || !settingsPanel.style.display ? "block" : "none"; +} + +function createPlaceholderForHost(host) { + const reportContainer = document.getElementById("reportContainer"); + + 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); + reportContainer.appendChild(nodeBlock); +} + +function displaySingleReport(data, host) { + const nodeBlock = document.getElementById(`report-${host.replace(/[:.]/g, '-')}`); + const statusCircle = nodeBlock.querySelector('.status-circle'); + + statusCircle.classList.remove('status-loading'); + + if (data.successful && data.report) { + statusCircle.classList.add('status-ok'); + const mainTable = createTable(data.report, "Report", 0); + nodeBlock.appendChild(mainTable); + } else { + statusCircle.classList.add('status-error'); + const errorDiv = document.createElement('div'); + errorDiv.classList.add('error'); + + errorDiv.textContent = data.errorMessage || "Failed to fetch report"; + nodeBlock.appendChild(errorDiv); + } +} + +function 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 = formatColumnName(key); + th.title = key; + headerRow.appendChild(th); + + const td = document.createElement('td'); + const value = data[key]; + + if (complexKeys.includes(key)) { + const button = document.createElement('button'); + button.classList.add('toggle-button'); + button.textContent = "Expand Details"; + button.dataset.key = key; + button.dataset.value = JSON.stringify(value); + button.dataset.depth = depth; + button.tableDiv = tableDiv; + button.onclick = () => toggleDetailButton(button); + td.appendChild(button); + } else { + td.textContent = value !== undefined ? value : "N/A"; + } + valueRow.appendChild(td); + }); + + table.appendChild(headerRow); + table.appendChild(valueRow); + tableDiv.appendChild(table); + + return tableDiv; +} +