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(); + }); + + +
+
+

{data.meta.title}

+

+ Monero remote node is a device on the internet running the Monero software with + full copy of the Monero blockchain that doesn't run on the same local machine where the Monero + wallet is located. +

+
+
+
+
+
+ +
+
+

+ 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. +

+
+
+ +
+
+
+
+ + +
+ +
+
+ + + + + + + + + + + + Est. Fee + Date Checked + Runtime + + + + + + + + + {#each $rows as row (row.id)} + + + + + {#if row.status !== 1} + + {:else} + + + + + + {/if} + + + + {/each} + +
#IDHeightAdjusted TimeDB SizeDifficulty
+ + + +
{row.id}{row.prober_id}{row.status === 1 ? 'OK' : 'ERR'}{row.failed_reason ?? ''}{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)} + {format(row.date_checked * 1000, 'PP HH:mm')}
+ {formatDistance(row.date_checked * 1000, new Date(), { addSuffix: true })} +
{parseRuntime(row.fetch_runtime)}
+ +
+ + +
+
+
+
+ + 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;