Skip to content

Commit

Permalink
Simple HTML frontend for Bisq 2 monitor
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
bsc7 committed Oct 30, 2024
1 parent a726329 commit 686aecb
Show file tree
Hide file tree
Showing 10 changed files with 607 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public JaxRsApplication(String[] args, Supplier<RestApiApplicationService> appli
public CompletableFuture<Boolean> 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);
}
Expand Down
11 changes: 10 additions & 1 deletion apps/rest-api-app/src/main/java/bisq/rest_api/dto/ReportDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Report> 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);
}
}

Expand All @@ -86,16 +82,47 @@ public ReportDto getReport(@Parameter(description = "address from which we reque
)
@GET
@Path("get-reports/{addresses}")
public List<ReportDto> getReports(@Parameter(description = "comma separated addresses from which we request the report") @PathParam("addresses") String addresses) {
List<CompletableFuture<Report>> futures = CollectionUtil.streamFromCsv(addresses)
.map(address -> networkService.requestReport(Address.fromFullAddress(address)))
public List<ReportDto> getReports(
@Parameter(description = "comma separated addresses from which we request the report")
@PathParam("addresses") String addresses) {

List<String> addressList;
try {
addressList = CollectionUtil.streamFromCsv(addresses).toList();
} catch (Exception e) {
throw new RuntimeException("Failed to parse addresses from CSV input: " + addresses);
}


List<CompletableFuture<ReportDto>> futures = addressList.stream()
.map(this::fetchReportForAddress)
.toList();

CompletableFuture<Void> allFutures = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));

return allFutures.thenApply(v -> futures.stream()
.map(CompletableFuture::join)
.toList())
.join();
}

private CompletableFuture<ReportDto> fetchReportForAddress(String address) {
try {
List<Report> 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())
);
}
}
}
65 changes: 65 additions & 0 deletions apps/rest-api-app/src/main/resources/node-monitor/app.js
Original file line number Diff line number Diff line change
@@ -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";
}
});
});
}

41 changes: 41 additions & 0 deletions apps/rest-api-app/src/main/resources/node-monitor/eventHandlers.js
Original file line number Diff line number Diff line change
@@ -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);
});
82 changes: 82 additions & 0 deletions apps/rest-api-app/src/main/resources/node-monitor/helpers.js
Original file line number Diff line number Diff line change
@@ -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));
});
}

47 changes: 47 additions & 0 deletions apps/rest-api-app/src/main/resources/node-monitor/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bisq Node Monitor</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>

<h2>Bisq Node Monitor</h2>

<div class="hamburger-menu" id="hamburgerButton">
&#9776;
</div>

<div id="settingsPanel" style="display: none;">
<h3>Settings</h3>
<div>
<label>Host List (comma or newline separated):</label>
<textarea id="hostListInput" placeholder="Host:port list, separated by commas or new lines" rows="3" style="width: 100%;"></textarea>
</div>
<div>
<label>Port List (comma or newline separated, optional):</label>
<textarea id="portListInput" placeholder="Port list for filtering, separated by commas or new lines" rows="2" style="width: 100%;"></textarea>
</div>
<button id="saveConfigButton" class="toggle-button" style="margin-top: 10px;">Save Configuration</button>
</div>

<button id="toggleAllButton" class="toggle-all-button">Expand All Details</button>

<div id="statusMessage" style="display: none; text-align: center; margin-top: 20px; color: grey;">
<!-- Dynamically generated message data will appear here -->
</div>

<div id="reportContainer">
<!-- Dynamically generated node data will appear here -->
</div>

<script src="helpers.js"></script>
<script src="toggleFunctions.js"></script>
<script src="ui.js"></script>
<script src="app.js"></script>
<script src="eventHandlers.js"></script>
</body>
</html>

Loading

0 comments on commit 686aecb

Please sign in to comment.