Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simple HTML frontend for Bisq 2 monitor #2969

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading