Monero remote node UI for frontend

This commit also implement the simple remote node queries.

TODO: Add filter for various data
This commit is contained in:
Cristian Ditaputratama 2024-05-04 18:52:47 +07:00
parent 7cd802e640
commit ca759fc1d0
Signed by: ditatompel
GPG key ID: 31D3D06D77950979
6 changed files with 297 additions and 24 deletions

View file

@ -1,5 +1,82 @@
<script>
import { DataHandler } from '@vincjo/datatables/remote';
import { format, formatDistance } from 'date-fns';
import { loadData } from './api-handler';
import { onMount, onDestroy } from 'svelte';
import {
DtSrRowsPerPage,
DtSrThSort,
DtSrThFilter,
DtSrRowCount,
DtSrPagination
} from '$lib/components/datatables/server';
import {
HostPortCell,
NetTypeCell,
ProtocolCell,
CountryCellWithAsn,
StatusCell,
UptimeCell,
EstimateFeeCell
} from './components';
export let data;
let filterNettype = 'any';
let filterProtocol = 'any';
let filterCc = 'any';
let filterStatus = -1;
let checkboxCors = false;
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(() => {
handler.onChange((state) => loadData(state));
handler.invalidate();
});
</script>
<header id="hero" class="hero-gradient py-7">
@ -36,19 +113,40 @@
<div class="section-container">
<div class="space-y-2 overflow-x-auto">
<div class="flex justify-between">
<!-- <DtSrRowsPerPage {handler} /> -->
<DtSrRowsPerPage {handler} />
<div class="invisible flex place-items-center md:visible">
<label for="autoRefreshInterval">Auto Refresh:</label>
<select
class="select ml-2"
id="autoRefreshInterval"
bind:value={intervalValue}
on:change={startInterval}
>
{#each intervalOptions as { value, label }}
<option {value}>{label}</option>
{/each}
</select>
</div>
<div class="flex place-items-center">
<button
id="reloadDt"
name="reloadDt"
class="variant-filled-primary btn"
on:click={reloadData}>Reload</button
>
</div>
</div>
<!--
<table class="table table-hover table-compact w-full table-auto">
<thead>
<tr>
<th>Host:Port</th>
<th><label for="fNettype">Nettype</label></th>
<th><label for="fProtocol">Protocol</label></th>
<th><label for="fCc">Country</label></th>
<th><label for="fStatus">Status</label></th>
<th>Nettype</th>
<th>Protocol</th>
<th>Country</th>
<th>Status</th>
<th>Est. Fee</th>
<DtSrThSort {handler} orderBy="uptime">Uptime</DtSrThSort>
<DtSrThSort {handler} orderBy="last_checked">Check</DtSrThSort>
</tr>
@ -100,6 +198,7 @@
}}
>
<option value="any">Any</option>
<!--
{#each data.countries as country}
{#if country.cc === ''}
<option value="UNKNOWN">UNKNOWN ({country.total_nodes})</option>
@ -109,6 +208,7 @@
>
{/if}
{/each}
-->
</select>
</th>
<th colspan="2">
@ -146,7 +246,7 @@
</tr>
</thead>
<tbody>
{#each $rows as row}
{#each $rows as row (row.id)}
<tr>
<td
><HostPortCell
@ -173,23 +273,27 @@
statuses={row.last_check_statuses}
/></td
>
<td
><EstimateFeeCell
<td>
<!-- <EstimateFeeCell
estimate_fee={row.estimate_fee}
majority_fee={netFees[row.nettype]}
/></td
>
/>
-->
</td>
<td><UptimeCell uptime={row.uptime} /></td>
<td>{format(row.last_checked * 1000, 'PP HH:mm')}</td>
<td>
{format(row.last_checked * 1000, 'PP HH:mm')}<br />
{formatDistance(row.last_checked * 1000, new Date(), { addSuffix: true })}
</td>
</tr>
{/each}
</tbody>
</table>
<div class="flex justify-between">
<div class="flex justify-between mb-2">
<DtSrRowCount {handler} />
<DtSrPagination {handler} />
</div>
-->
</div>
</div>
</section>

View file

@ -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('');

View file

@ -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)

View file

@ -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)
}

View file

@ -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")

View file

@ -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 */;