From ca759fc1d044e3f2b6cf7e205a0a424d830f345d Mon Sep 17 00:00:00 2001 From: ditatompel Date: Sat, 4 May 2024 18:52:47 +0700 Subject: [PATCH] Monero remote node UI for frontend This commit also implement the simple remote node queries. TODO: Add filter for various data --- .../routes/(front)/remote-nodes/+page.svelte | 132 ++++++++++++++++-- .../(front)/remote-nodes/api-handler.js | 19 ++- handler/response.go | 26 ++++ handler/routes.go | 1 + internal/repo/monero.go | 110 +++++++++++++++ tools/resources/database/structure.sql | 33 +++++ 6 files changed, 297 insertions(+), 24 deletions(-) diff --git a/frontend/src/routes/(front)/remote-nodes/+page.svelte b/frontend/src/routes/(front)/remote-nodes/+page.svelte index e96e0f4..36100ab 100644 --- a/frontend/src/routes/(front)/remote-nodes/+page.svelte +++ b/frontend/src/routes/(front)/remote-nodes/+page.svelte @@ -1,5 +1,82 @@
@@ -36,19 +113,40 @@
- + + +
+ +
- @@ -146,7 +246,7 @@ - {#each $rows as row} + {#each $rows as row (row.id)} - + + - {format(row.last_checked * 1000, 'PP HH:mm')} + + {format(row.last_checked * 1000, 'PP HH:mm')}
+ {formatDistance(row.last_checked * 1000, new Date(), { addSuffix: true })} + {/each} -
+ +
- -->
diff --git a/frontend/src/routes/(front)/remote-nodes/api-handler.js b/frontend/src/routes/(front)/remote-nodes/api-handler.js index 5726f3c..138745a 100644 --- a/frontend/src/routes/(front)/remote-nodes/api-handler.js +++ b/frontend/src/routes/(front)/remote-nodes/api-handler.js @@ -1,19 +1,18 @@ -import { PUBLIC_API_ENDPOINT } from '$env/static/public'; +import { apiUri } from '$lib/utils/common'; /** @param {import('@vincjo/datatables/remote/state')} state */ -export async function loadApiData(state) { - const response = await fetch(`${PUBLIC_API_ENDPOINT}/monero/remote-node-dt?${getParams(state)}`); +export const loadData = async (state) => { + const response = await fetch(apiUri(`/api/v1/nodes?${getParams(state)}`)); const json = await response.json(); + state.setTotalRows(json.data.total_rows ?? 0); + return json.data.items ?? []; +}; - state.setTotalRows(json.data.total ?? 0); - - return json.data.nodes ?? []; -} - -const getParams = ({ pageNumber, offset, rowsPerPage, sort, filters }) => { +const getParams = ({ pageNumber, rowsPerPage, sort, filters }) => { let params = `page=${pageNumber}&limit=${rowsPerPage}`; + if (sort) { - params += `&sort=${sort.orderBy}&dir=${sort.direction}`; + params += `&sort_by=${sort.orderBy}&sort_direction=${sort.direction}`; } if (filters) { params += filters.map(({ filterBy, value }) => `&${filterBy}=${value}`).join(''); diff --git a/handler/response.go b/handler/response.go index 71332c2..87f1502 100644 --- a/handler/response.go +++ b/handler/response.go @@ -115,6 +115,32 @@ func Prober(c *fiber.Ctx) error { }) } +func MoneroNodes(c *fiber.Ctx) error { + moneroRepo := repo.NewMoneroRepo(database.GetDB()) + query := repo.MoneroQueryParams{ + RowsPerPage: c.QueryInt("limit", 10), + Page: c.QueryInt("page", 1), + SortBy: c.Query("sort_by", "id"), + SortDirection: c.Query("sort_direction", "desc"), + Host: c.Query("host"), + } + + nodes, err := moneroRepo.Nodes(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": nodes, + }) +} + 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 cae95e6..235f666 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -14,6 +14,7 @@ func V1Api(app *fiber.App) { v1.Get("/prober", Prober) v1.Post("/prober", Prober) + v1.Get("/nodes", MoneroNodes) v1.Post("/nodes", AddNode) v1.Get("/crons", Crons) } diff --git a/internal/repo/monero.go b/internal/repo/monero.go index 6eb416a..57c431c 100644 --- a/internal/repo/monero.go +++ b/internal/repo/monero.go @@ -3,15 +3,20 @@ package repo import ( "encoding/json" "errors" + "fmt" "net" + "slices" "strings" "time" "github.com/ditatompel/xmr-nodes/internal/database" + + "github.com/jmoiron/sqlx/types" ) type MoneroRepository interface { Add(protocol string, host string, port uint) error + Nodes(q MoneroQueryParams) (MoneroNodes, error) } type MoneroRepo struct { @@ -22,6 +27,111 @@ func NewMoneroRepo(db *database.DB) MoneroRepository { return &MoneroRepo{db} } +type MoneroNode struct { + Id uint `json:"id,omitempty" db:"id"` + Hostname string `json:"hostname" db:"hostname"` + Ip string `json:"ip" db:"ip_addr"` + Port uint `json:"port" db:"port"` + Protocol string `json:"protocol" db:"protocol"` + IsTor bool `json:"is_tor" db:"is_tor"` + IsAvailable bool `json:"is_available" db:"is_available"` + NetType string `json:"nettype" db:"nettype"` + LastHeight uint `json:"last_height" db:"last_height"` + AdjustedTime uint `json:"adjusted_time" db:"adjusted_time"` + DatabaseSize uint `json:"database_size" db:"database_size"` + Difficulty uint `json:"difficulty" db:"difficulty"` + NodeVersion string `json:"node_version" db:"node_version"` + Uptime float32 `json:"uptime" db:"uptime"` + EstimateFee uint `json:"estimate_fee" db:"estimate_fee"` + Asn uint `json:"asn" db:"asn"` + AsnName string `json:"asn_name" db:"asn_name"` + CountryCode string `json:"cc" db:"country"` + CountryName string `json:"country_name" db:"country_name"` + City string `json:"city" db:"city"` + Lat float64 `json:"latitude" db:"lat"` + Lon float64 `json:"longitude" db:"lon"` + DateEntered uint `json:"date_entered,omitempty" db:"date_entered"` + LastChecked uint `json:"last_checked" db:"last_checked"` + FailedCount uint `json:"failed_count,omitempty" db:"failed_count"` + LastCheckStatus types.JSONText `json:"last_check_statuses" db:"last_check_status"` + CorsCapable bool `json:"cors" db:"cors_capable"` +} + +type MoneroNodes struct { + TotalRows int `json:"total_rows"` + RowsPerPage int `json:"rows_per_page"` + CurrentPage int `json:"current_page"` + NextPage int `json:"next_page"` + Items []*MoneroNode `json:"items"` +} + +type MoneroQueryParams struct { + Host string + RowsPerPage int + Page int + SortBy string + SortDirection string +} + +func (repo *MoneroRepo) Nodes(q MoneroQueryParams) (MoneroNodes, error) { + queryParams := []interface{}{} + whereQueries := []string{} + where := "" + + if q.Host != "" { + whereQueries = append(whereQueries, "(hostname LIKE ? OR ip_addr LIKE ?)") + queryParams = append(queryParams, "%"+q.Host+"%") + queryParams = append(queryParams, "%"+q.Host+"%") + } + + if len(whereQueries) > 0 { + where = "WHERE " + strings.Join(whereQueries, " AND ") + } + + nodes := MoneroNodes{} + + queryTotalRows := fmt.Sprintf("SELECT COUNT(id) AS total_rows FROM tbl_node %s", where) + + err := repo.db.QueryRow(queryTotalRows, queryParams...).Scan(&nodes.TotalRows) + if err != nil { + return nodes, err + } + queryParams = append(queryParams, q.RowsPerPage, (q.Page-1)*q.RowsPerPage) + + allowedSort := []string{"last_checked", "uptime"} + sortBy := "last_checked" + if slices.Contains(allowedSort, q.SortBy) { + sortBy = q.SortBy + } + sortDirection := "DESC" + if q.SortDirection == "asc" { + sortDirection = "ASC" + } + + query := fmt.Sprintf("SELECT id, protocol, hostname, port, is_tor, is_available, nettype, last_height, adjusted_time, database_size, difficulty, node_version, uptime, estimate_fee, ip_addr, asn, asn_name, country, country_name, city, lat, lon, date_entered, last_checked, last_check_status, cors_capable FROM tbl_node %s ORDER BY %s %s LIMIT ? OFFSET ?", where, sortBy, sortDirection) + + row, err := repo.db.Query(query, queryParams...) + if err != nil { + return nodes, err + } + defer row.Close() + + nodes.RowsPerPage = q.RowsPerPage + nodes.CurrentPage = q.Page + nodes.NextPage = q.Page + 1 + + for row.Next() { + node := MoneroNode{} + err = row.Scan(&node.Id, &node.Protocol, &node.Hostname, &node.Port, &node.IsTor, &node.IsAvailable, &node.NetType, &node.LastHeight, &node.AdjustedTime, &node.DatabaseSize, &node.Difficulty, &node.NodeVersion, &node.Uptime, &node.EstimateFee, &node.Ip, &node.Asn, &node.AsnName, &node.CountryName, &node.CountryCode, &node.City, &node.Lat, &node.Lon, &node.DateEntered, &node.LastChecked, &node.LastCheckStatus, &node.CorsCapable) + if err != nil { + return nodes, err + } + nodes.Items = append(nodes.Items, &node) + } + + return nodes, 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 f88b2fb..8913e7b 100644 --- a/tools/resources/database/structure.sql +++ b/tools/resources/database/structure.sql @@ -39,6 +39,39 @@ CREATE TABLE `tbl_cron` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `tbl_node`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tbl_node` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `protocol` varchar(6) NOT NULL DEFAULT 'http' COMMENT 'http | https', + `hostname` varchar(200) NOT NULL DEFAULT '', + `port` int(6) unsigned NOT NULL DEFAULT 0, + `is_tor` tinyint(1) unsigned NOT NULL DEFAULT 0, + `is_available` tinyint(1) unsigned NOT NULL DEFAULT 0, + `nettype` varchar(100) NOT NULL COMMENT 'mainnet | stagenet | testnet', + `last_height` bigint(20) unsigned NOT NULL DEFAULT 0, + `adjusted_time` bigint(20) unsigned NOT NULL DEFAULT 0, + `database_size` bigint(20) unsigned NOT NULL DEFAULT 0, + `difficulty` bigint(20) unsigned NOT NULL DEFAULT 0, + `node_version` varchar(200) NOT NULL DEFAULT '', + `uptime` float(5,2) unsigned NOT NULL DEFAULT 0.00, + `estimate_fee` int(9) unsigned NOT NULL DEFAULT 0, + `ip_addr` varchar(200) NOT NULL, + `asn` int(9) unsigned NOT NULL DEFAULT 0, + `asn_name` varchar(200) NOT NULL DEFAULT '', + `country` varchar(200) NOT NULL DEFAULT '', + `country_name` varchar(255) NOT NULL DEFAULT '', + `city` varchar(200) NOT NULL DEFAULT '', + `lat` float NOT NULL DEFAULT 0 COMMENT 'latitude', + `lon` float NOT NULL DEFAULT 0 COMMENT 'longitude', + `date_entered` bigint(20) unsigned NOT NULL DEFAULT 0, + `last_checked` bigint(20) unsigned NOT NULL DEFAULT 0, + `last_check_status` text DEFAULT NULL, + `cors_capable` tinyint(1) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; DROP TABLE IF EXISTS `tbl_prober`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */;