diff --git a/frontend/src/routes/(front)/remote-nodes/+page.svelte b/frontend/src/routes/(front)/remote-nodes/+page.svelte
index 812fca3..a129ae5 100644
--- a/frontend/src/routes/(front)/remote-nodes/+page.svelte
+++ b/frontend/src/routes/(front)/remote-nodes/+page.svelte
@@ -293,8 +293,9 @@
is_tor={row.is_tor}
hostname={row.hostname}
port={row.port}
- />
+ />
+ [Logs]
+
+ import { DataHandler } from '@vincjo/datatables/remote';
+ import { format, formatDistance } from 'date-fns';
+ import { loadData, formatBytes } from './api-handler';
+ import { onMount, onDestroy } from 'svelte';
+ import {
+ DtSrRowsPerPage,
+ DtSrThSort,
+ DtSrThFilter,
+ DtSrRowCount,
+ DtSrPagination
+ } from '$lib/components/datatables/server';
+
+ /**
+ * @param {number} n
+ * @param {number} p
+ */
+ function maxPrecision(n, p) {
+ return parseFloat(n.toFixed(p));
+ }
+
+ /**
+ * @param {number} h
+ */
+ function formatHashes(h) {
+ if (h < 1e-12) return '0 H';
+ else if (h < 1e-9) return maxPrecision(h * 1e12, 0) + ' pH';
+ else if (h < 1e-6) return maxPrecision(h * 1e9, 0) + ' nH';
+ else if (h < 1e-3) return maxPrecision(h * 1e6, 0) + ' μH';
+ else if (h < 1) return maxPrecision(h * 1e3, 0) + ' mH';
+ else if (h < 1e3) return h + ' H';
+ else if (h < 1e6) return maxPrecision(h * 1e-3, 2) + ' KH';
+ else if (h < 1e9) return maxPrecision(h * 1e-6, 2) + ' MH';
+ else return maxPrecision(h * 1e-9, 2) + ' GH';
+ }
+
+ /** @param {number | null } runtime */
+ function parseRuntime(runtime) {
+ return runtime === null ? '' : runtime.toLocaleString(undefined) + 's';
+ }
+
+ export let data;
+
+ let pageId = '0';
+ let filterProberId = 0;
+ let filterStatus = -1;
+
+ const handler = new DataHandler([], { rowsPerPage: 10, totalRows: 0 });
+ let rows = handler.getRows();
+
+ const reloadData = () => {
+ handler.invalidate();
+ };
+
+ /** @type {number | undefined} */
+ let intervalId;
+ let intervalValue = 0;
+
+ const intervalOptions = [
+ { value: 0, label: 'No' },
+ { value: 5, label: '5s' },
+ { value: 10, label: '10s' },
+ { value: 30, label: '30s' },
+ { value: 60, label: '1m' }
+ ];
+
+ const startInterval = () => {
+ const seconds = intervalValue;
+ if (isNaN(seconds) || seconds < 0) {
+ return;
+ }
+
+ if (!intervalOptions.some((option) => option.value === seconds)) {
+ return;
+ }
+
+ if (intervalId) {
+ clearInterval(intervalId);
+ }
+
+ if (seconds > 0) {
+ reloadData();
+ intervalId = setInterval(() => {
+ reloadData();
+ }, seconds * 1000);
+ }
+ };
+
+ $: startInterval(); // Automatically start the interval on change
+
+ onDestroy(() => {
+ clearInterval(intervalId); // Clear the interval when the component is destroyed
+ });
+ onMount(() => {
+ pageId = new URLSearchParams(window.location.search).get('node_id') || '0';
+ handler.filter(pageId, 'node_id');
+ handler.onChange((state) => loadData(state));
+ handler.invalidate();
+ });
+
+
+
+
+
+
+
+ Remote node can be used by people who, for their own reasons (usually because of hardware
+ requirements, disk space, or technical abilities), cannot/don't want to run their own node and
+ prefer to relay on one publicly available on the Monero network.
+
+
+ Using an open node will allow to make a transaction instantaneously, without the need to
+ download the blockchain and sync to the Monero network first, but at the cost of the control
+ over your privacy. the Monero community suggests to always run your own node to
+ obtain the maximum possible privacy and to help decentralize the network.
+
+
+
+
+
+
+
+
+
+
+ Auto Refresh:
+
+ {#each intervalOptions as { value, label }}
+ {label}
+ {/each}
+
+
+
+ Reload
+
+
+
+
+
+
+ #ID
+ Prober
+ Status
+ Height
+ Adjusted Time
+ DB Size
+ Difficulty
+ Est. Fee
+ Date Checked
+ Runtime
+
+
+
+ {
+ handler.filter(filterProberId, 'prober_id');
+ handler.invalidate();
+ }}
+ >
+ Any
+
+
+
+ {
+ handler.filter(filterStatus, 'status');
+ handler.invalidate();
+ }}
+ >
+ Any
+ Online
+ Offline
+
+
+
+
+
+
+ {#each $rows as row (row.id)}
+
+ {row.id}
+ {row.prober_id}
+ {row.status === 1 ? 'OK' : 'ERR'}
+ {#if row.status !== 1}
+ {row.failed_reason ?? ''}
+ {:else}
+ {row.height.toLocaleString(undefined)}
+ {format(row.adjusted_time * 1000, 'yyyy-MM-dd HH:mm')}
+ {formatBytes(row.database_size, 2)}
+ {formatHashes(row.difficulty)}
+ {row.estimate_fee.toLocaleString(undefined)}
+ {/if}
+
+ {format(row.date_checked * 1000, 'PP HH:mm')}
+ {formatDistance(row.date_checked * 1000, new Date(), { addSuffix: true })}
+
+ {parseRuntime(row.fetch_runtime)}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/routes/(front)/remote-nodes/logs/api-handler.js b/frontend/src/routes/(front)/remote-nodes/logs/api-handler.js
new file mode 100644
index 0000000..5be885e
--- /dev/null
+++ b/frontend/src/routes/(front)/remote-nodes/logs/api-handler.js
@@ -0,0 +1,38 @@
+import { apiUri } from '$lib/utils/common';
+
+/** @param {import('@vincjo/datatables/remote/state')} state */
+export const loadData = async (state) => {
+ const response = await fetch(apiUri(`/api/v1/nodes/logs?${getParams(state)}`));
+ const json = await response.json();
+ state.setTotalRows(json.data.total_rows ?? 0);
+ return json.data.items ?? [];
+};
+
+/**
+ * @param {number} bytes
+ * @param {number} decimals
+ * @returns {string}
+ */
+export const formatBytes = (bytes, decimals = 2) => {
+ if (!+bytes) return '0 Bytes';
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
+};
+
+const getParams = ({ pageNumber, rowsPerPage, sort, filters }) => {
+ let params = `page=${pageNumber}&limit=${rowsPerPage}`;
+
+ if (sort) {
+ params += `&sort_by=${sort.orderBy}&sort_direction=${sort.direction}`;
+ }
+ if (filters) {
+ params += filters.map(({ filterBy, value }) => `&${filterBy}=${value}`).join('');
+ }
+ return params;
+};
diff --git a/handler/response.go b/handler/response.go
index 3b5ac56..cc26a13 100644
--- a/handler/response.go
+++ b/handler/response.go
@@ -146,6 +146,32 @@ func MoneroNodes(c *fiber.Ctx) error {
})
}
+func ProbeLogs(c *fiber.Ctx) error {
+ moneroRepo := repo.NewMoneroRepo(database.GetDB())
+ query := repo.MoneroLogQueryParams{
+ RowsPerPage: c.QueryInt("limit", 10),
+ Page: c.QueryInt("page", 1),
+ SortBy: c.Query("sort_by", "id"),
+ SortDirection: c.Query("sort_direction", "desc"),
+ NodeId: c.QueryInt("node_id", 0),
+ }
+
+ logs, err := moneroRepo.Logs(query)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "status": "error",
+ "message": err.Error(),
+ "data": nil,
+ })
+ }
+
+ return c.JSON(fiber.Map{
+ "status": "ok",
+ "message": "Success",
+ "data": logs,
+ })
+}
+
func AddNode(c *fiber.Ctx) error {
formPort := c.FormValue("port")
port, err := strconv.Atoi(formPort)
diff --git a/handler/routes.go b/handler/routes.go
index 2403552..64a5058 100644
--- a/handler/routes.go
+++ b/handler/routes.go
@@ -16,6 +16,7 @@ func V1Api(app *fiber.App) {
v1.Post("/prober", Prober)
v1.Get("/nodes", MoneroNodes)
v1.Post("/nodes", AddNode)
+ v1.Get("/nodes/logs", ProbeLogs)
v1.Get("/fees", NetFee)
v1.Get("/countries", Countries)
v1.Get("/job", CheckProber, GiveJob)
diff --git a/internal/repo/monero.go b/internal/repo/monero.go
index b756d2f..abe576f 100644
--- a/internal/repo/monero.go
+++ b/internal/repo/monero.go
@@ -22,6 +22,7 @@ type MoneroRepository interface {
ProcessJob(report ProbeReport, proberId int64) error
NetFee() []NetFee
Countries() ([]MoneroCountries, error)
+ Logs(q MoneroLogQueryParams) (MoneroNodeFetchLogs, error)
}
type MoneroRepo struct {
@@ -173,6 +174,95 @@ func (repo *MoneroRepo) Nodes(q MoneroQueryParams) (MoneroNodes, error) {
return nodes, nil
}
+type MoneroLogQueryParams struct {
+ NodeId int // 0 fpr all, >0 for specific node
+ WorkerId int // 0 for all, >0 for specific worker
+ Status int // -1 for all, 0 for failed, 1 for success
+ FailReason string // empty for all, if not empty, will be used as search from failed_reaso
+
+ RowsPerPage int
+ Page int
+ SortBy string
+ SortDirection string
+}
+
+type ProbeLog struct {
+ Id int `db:"id" json:"id,omitempty"`
+ NodeId int `db:"node_id" json:"node_id"`
+ ProberId int `db:"prober_id" json:"prober_id"`
+ Status int `db:"is_available" json:"status"`
+ Height int `db:"height" json:"height"`
+ AdjustedTime int `db:"adjusted_time" json:"adjusted_time"`
+ DatabaseSize int `db:"database_size" json:"database_size"`
+ Difficulty int `db:"difficulty" json:"difficulty"`
+ EstimateFee int `db:"estimate_fee" json:"estimate_fee"`
+ DateChecked int `db:"date_checked" json:"date_checked"`
+ FailedReason string `db:"failed_reason" json:"failed_reason"`
+ FetchRuntime float64 `db:"fetch_runtime" json:"fetch_runtime"`
+}
+
+type MoneroNodeFetchLogs struct {
+ TotalRows int `json:"total_rows"`
+ RowsPerPage int `json:"rows_per_page"`
+ Items []*ProbeLog `json:"items"`
+}
+
+func (repo *MoneroRepo) Logs(q MoneroLogQueryParams) (MoneroNodeFetchLogs, error) {
+ queryParams := []interface{}{}
+ whereQueries := []string{}
+ where := ""
+
+ if q.NodeId != 0 {
+ whereQueries = append(whereQueries, "node_id = ?")
+ queryParams = append(queryParams, q.NodeId)
+ }
+
+ if len(whereQueries) > 0 {
+ where = "WHERE " + strings.Join(whereQueries, " AND ")
+ }
+
+ fetchLogs := MoneroNodeFetchLogs{}
+
+ queryTotalRows := fmt.Sprintf("SELECT COUNT(id) FROM tbl_probe_log %s", where)
+ err := repo.db.QueryRow(queryTotalRows, queryParams...).Scan(&fetchLogs.TotalRows)
+ if err != nil {
+ return fetchLogs, err
+ }
+ queryParams = append(queryParams, q.RowsPerPage, (q.Page-1)*q.RowsPerPage)
+
+ allowedSort := []string{"date_checked", "fetch_runtime"}
+ sortBy := "id"
+ if slices.Contains(allowedSort, q.SortBy) {
+ sortBy = q.SortBy
+ }
+ sortDirection := "DESC"
+ if q.SortDirection == "asc" {
+ sortDirection = "ASC"
+ }
+
+ query := fmt.Sprintf("SELECT id, node_id, prober_id, is_available, height, adjusted_time, database_size, difficulty, estimate_fee, date_checked, failed_reason, fetch_runtime FROM tbl_probe_log %s ORDER BY %s %s LIMIT ? OFFSET ?", where, sortBy, sortDirection)
+
+ row, err := repo.db.Query(query, queryParams...)
+ if err != nil {
+ return fetchLogs, err
+ }
+ defer row.Close()
+
+ fetchLogs.RowsPerPage = q.RowsPerPage
+
+ for row.Next() {
+ probeLog := ProbeLog{}
+ err = row.Scan(&probeLog.Id, &probeLog.NodeId, &probeLog.ProberId, &probeLog.Status, &probeLog.Height, &probeLog.AdjustedTime, &probeLog.DatabaseSize, &probeLog.Difficulty, &probeLog.EstimateFee, &probeLog.DateChecked, &probeLog.FailedReason, &probeLog.FetchRuntime)
+ if err != nil {
+ return fetchLogs, err
+ }
+
+ fetchLogs.Items = append(fetchLogs.Items, &probeLog)
+ }
+
+ return fetchLogs, nil
+}
+
func (repo *MoneroRepo) Add(protocol string, hostname string, port uint) error {
if protocol != "http" && protocol != "https" {
return errors.New("Invalid protocol, must one of or HTTP/HTTPS")
diff --git a/tools/resources/database/structure.sql b/tools/resources/database/structure.sql
index cf8eca5..b0b723e 100644
--- a/tools/resources/database/structure.sql
+++ b/tools/resources/database/structure.sql
@@ -87,7 +87,7 @@ CREATE TABLE `tbl_probe_log` (
`estimate_fee` int(9) unsigned NOT NULL DEFAULT 0,
`date_checked` bigint(20) unsigned NOT NULL DEFAULT 0,
`failed_reason` text NOT NULL DEFAULT '',
- `fetch_runtime` float(5,2) unsigned DEFAULT NULL,
+ `fetch_runtime` float(5,2) unsigned NOT NULL DEFAULT 0.00,
PRIMARY KEY (`id`),
KEY `node_id` (`node_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;