mirror of
https://github.com/ditatompel/xmr-remote-nodes.git
synced 2025-04-09 21:16:28 +07:00
Compare commits
13 commits
fdf541f78f
...
b68f626ce2
Author | SHA1 | Date | |
---|---|---|---|
b68f626ce2 | |||
2e31824910 | |||
5fb88865d0 | |||
f227371fa6 | |||
3f5c2b9905 | |||
df161f831a | |||
75e97b4e0c | |||
9e1da3c79a | |||
0f011572f5 | |||
3beb3ba60e | |||
fb6f6c2b5c | |||
1eb26210f6 | |||
95b371a056 |
16 changed files with 1170 additions and 320 deletions
|
@ -12,6 +12,7 @@ IPV6_CAPABLE=false
|
|||
|
||||
# Server Config
|
||||
# #############
|
||||
APP_URL="https://xmr.ditatompel.com" # URL where user can access the web UI, don't put trailing slash
|
||||
|
||||
# Fiber Config
|
||||
APP_PREFORK=false
|
||||
|
|
|
@ -13,6 +13,9 @@ type App struct {
|
|||
LogLevel string
|
||||
|
||||
// configuration for server
|
||||
URL string // URL where user can access the web UI, don't put trailing slash
|
||||
|
||||
// fiber specific config
|
||||
Prefork bool
|
||||
Host string
|
||||
Port int
|
||||
|
@ -55,6 +58,9 @@ func LoadApp() {
|
|||
}
|
||||
|
||||
// server configuration
|
||||
app.URL = os.Getenv("APP_URL")
|
||||
|
||||
// fiber specific config
|
||||
app.Host = os.Getenv("APP_HOST")
|
||||
app.Port, _ = strconv.Atoi(os.Getenv("APP_PORT"))
|
||||
app.Prefork, _ = strconv.ParseBool(os.Getenv("APP_PREFORK"))
|
||||
|
|
|
@ -6,7 +6,8 @@ import (
|
|||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func CheckProber(c *fiber.Ctx) error {
|
||||
// checkProberMW is a middleware to check prober API key
|
||||
func (s *fiberServer) checkProberMW(c *fiber.Ctx) error {
|
||||
key := c.Get(monero.ProberAPIKey)
|
||||
if key == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
|
|
|
@ -13,6 +13,19 @@ import (
|
|||
"github.com/gofiber/fiber/v2/middleware/adaptor"
|
||||
)
|
||||
|
||||
// Redirect old `/remote-nodes/logs/?node_id={id}` path to `/remote-nodes/id/{id}`
|
||||
//
|
||||
// This is temporary handler to redirect old path to new one. Once search
|
||||
// engine results updated to the new path, this handler should be removed.
|
||||
func (s *fiberServer) redirectLogs(c *fiber.Ctx) error {
|
||||
id := c.QueryInt("node_id", 0)
|
||||
if id == 0 {
|
||||
return c.Redirect("/remote-nodes", fiber.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
return c.Redirect(fmt.Sprintf("/remote-nodes/id/%d", id), fiber.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// Render Home Page
|
||||
func (s *fiberServer) homeHandler(c *fiber.Ctx) error {
|
||||
p := views.Meta{
|
||||
|
@ -20,7 +33,7 @@ func (s *fiberServer) homeHandler(c *fiber.Ctx) error {
|
|||
Description: "A website that helps you monitor your favourite Monero remote nodes, but YOU BETTER RUN AND USE YOUR OWN NODE.",
|
||||
Keywords: "monero,monero,xmr,monero node,xmrnode,cryptocurrency,monero remote node,monero testnet,monero stagenet",
|
||||
Robots: "INDEX,FOLLOW",
|
||||
Permalink: "https://xmr.ditatompel.com",
|
||||
Permalink: s.url,
|
||||
Identifier: "/",
|
||||
}
|
||||
|
||||
|
@ -61,7 +74,7 @@ func (s *fiberServer) addNodeHandler(c *fiber.Ctx) error {
|
|||
Description: "You can use this page to add known remote node to the system so my bots can monitor it.",
|
||||
Keywords: "monero,monero node,monero public node,monero wallet,list monero node,monero node monitoring",
|
||||
Robots: "INDEX,FOLLOW",
|
||||
Permalink: "https://xmr.ditatompel.com/add-node",
|
||||
Permalink: s.url + "/add-node",
|
||||
Identifier: "/add-node",
|
||||
}
|
||||
|
||||
|
@ -72,8 +85,8 @@ func (s *fiberServer) addNodeHandler(c *fiber.Ctx) error {
|
|||
return handler(c)
|
||||
}
|
||||
|
||||
// Returns a single node information based on `id` query param
|
||||
func Node(c *fiber.Ctx) error {
|
||||
// Returns a single node information based on `id` query param (API endpoint, JSON data)
|
||||
func (s *fiberServer) nodeAPI(c *fiber.Ctx) error {
|
||||
nodeId, err := c.ParamsInt("id", 0)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||||
|
@ -115,7 +128,7 @@ func (s *fiberServer) remoteNodesHandler(c *fiber.Ctx) error {
|
|||
Description: "Although it's possible to use these existing public Monero nodes, you're MUST RUN AND USE YOUR OWN NODE!",
|
||||
Keywords: "monero remote nodes,public monero nodes,monero public nodes,monero wallet,tor monero node,monero cors rpc",
|
||||
Robots: "INDEX,FOLLOW",
|
||||
Permalink: "https://xmr.ditatompel.com/remote-nodes",
|
||||
Permalink: s.url + "/remote-nodes",
|
||||
Identifier: "/remote-nodes",
|
||||
}
|
||||
|
||||
|
@ -171,49 +184,88 @@ func (s *fiberServer) remoteNodesHandler(c *fiber.Ctx) error {
|
|||
}
|
||||
|
||||
// Returns a single node information based on `id` query param.
|
||||
// For now, only process from HTMX request.
|
||||
// This used for node modal and node details page including node probe logs.
|
||||
func (s *fiberServer) nodeHandler(c *fiber.Ctx) error {
|
||||
nodeID, err := c.ParamsInt("id", 0)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
if nodeID == 0 {
|
||||
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": "Invalid node id",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
|
||||
moneroRepo := monero.New()
|
||||
node, err := moneroRepo.Node(nodeID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
switch c.Get("HX-Target") {
|
||||
case "modal-section":
|
||||
nodeID, err := c.ParamsInt("id", 0)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
if nodeID == 0 {
|
||||
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": "Invalid node id",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
moneroRepo := monero.New()
|
||||
node, err := moneroRepo.Node(nodeID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
cmp := views.ModalLayout(fmt.Sprintf("Node #%d", nodeID), views.Node(node))
|
||||
handler := adaptor.HTTPHandler(templ.Handler(cmp))
|
||||
return handler(c)
|
||||
}
|
||||
|
||||
// for now, just return 400
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": "Bad Request, invalid HTMX request",
|
||||
"data": nil,
|
||||
})
|
||||
queryLogs := monero.QueryLogs{
|
||||
Paging: paging.Paging{
|
||||
Limit: c.QueryInt("limit", 10), // rows per page
|
||||
Page: c.QueryInt("page", 1),
|
||||
SortBy: c.Query("sort_by", "id"),
|
||||
SortDirection: c.Query("sort_direction", "desc"),
|
||||
Refresh: c.Query("refresh"),
|
||||
},
|
||||
NodeID: int(node.ID),
|
||||
Status: c.QueryInt("status", -1),
|
||||
FailedReason: c.Query("failed_reason"),
|
||||
}
|
||||
|
||||
logs, err := moneroRepo.Logs(queryLogs)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
|
||||
pagination := paging.NewPagination(queryLogs.Page, logs.TotalPages)
|
||||
|
||||
// handle datatable logs filters, sort request from HTMX
|
||||
if c.Get("HX-Target") == "tbl_logs" {
|
||||
cmp := views.BlankLayout(views.TableLogs(fmt.Sprintf("/remote-nodes/id/%d", node.ID), logs, queryLogs, pagination))
|
||||
handler := adaptor.HTTPHandler(templ.Handler(cmp))
|
||||
return handler(c)
|
||||
}
|
||||
|
||||
p := views.Meta{
|
||||
Title: fmt.Sprintf("%s on Port %d", node.Hostname, node.Port),
|
||||
Description: fmt.Sprintf("Monero %s remote node %s running on port %d", node.Nettype, node.Hostname, node.Port),
|
||||
Keywords: fmt.Sprintf("monero log,monero node log,monitoring monero log,monero,xmr,monero node,xmrnode,cryptocurrency,monero %s,%s", node.Nettype, node.Hostname),
|
||||
Robots: "INDEX,FOLLOW",
|
||||
Permalink: s.url + "/remote-nodes/id/" + strconv.Itoa(int(node.ID)),
|
||||
Identifier: "/remote-nodes",
|
||||
}
|
||||
|
||||
c.Set("Link", fmt.Sprintf(`<%s>; rel="canonical"`, p.Permalink))
|
||||
cmp := views.BaseLayout(p, views.NodeDetails(node, logs, queryLogs, pagination))
|
||||
handler := adaptor.HTTPHandler(templ.Handler(cmp))
|
||||
return handler(c)
|
||||
}
|
||||
|
||||
// Returns a list of nodes (API)
|
||||
func Nodes(c *fiber.Ctx) error {
|
||||
// Returns list of nodes (API endpoint, JSON data)
|
||||
func (s *fiberServer) nodesAPI(c *fiber.Ctx) error {
|
||||
moneroRepo := monero.New()
|
||||
query := monero.QueryNodes{
|
||||
Paging: paging.Paging{
|
||||
|
@ -247,19 +299,20 @@ func Nodes(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// Returns probe logs reported by nodes
|
||||
//
|
||||
// The embadded web UI use `node_id` query param to filter logs
|
||||
func ProbeLogs(c *fiber.Ctx) error {
|
||||
// Returns probe logs reported by nodes (API endpoint, JSON data)
|
||||
func (s *fiberServer) probeLogsAPI(c *fiber.Ctx) error {
|
||||
moneroRepo := monero.New()
|
||||
query := monero.QueryLogs{
|
||||
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),
|
||||
Status: c.QueryInt("status", -1),
|
||||
FailedReason: c.Query("failed_reason"),
|
||||
Paging: paging.Paging{
|
||||
Limit: c.QueryInt("limit", 10), // rows per page
|
||||
Page: c.QueryInt("page", 1),
|
||||
SortBy: c.Query("sort_by", "id"),
|
||||
SortDirection: c.Query("sort_direction", "desc"),
|
||||
Refresh: c.Query("refresh"),
|
||||
},
|
||||
NodeID: c.QueryInt("node_id", 0),
|
||||
Status: c.QueryInt("status", -1),
|
||||
FailedReason: c.Query("failed_reason"),
|
||||
}
|
||||
|
||||
logs, err := moneroRepo.Logs(query)
|
||||
|
@ -281,7 +334,7 @@ func ProbeLogs(c *fiber.Ctx) error {
|
|||
// Handles `POST /nodes` request to add a new node
|
||||
//
|
||||
// Deprecated: AddNode is deprecated, use s.addNodeHandler with put method instead
|
||||
func AddNode(c *fiber.Ctx) error {
|
||||
func (s *fiberServer) addNodeAPI(c *fiber.Ctx) error {
|
||||
formPort := c.FormValue("port")
|
||||
port, err := strconv.Atoi(formPort)
|
||||
if err != nil {
|
||||
|
@ -311,8 +364,8 @@ func AddNode(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// Returns majority network fees
|
||||
func NetFees(c *fiber.Ctx) error {
|
||||
// Returns majority network fees (API endpoint, JSON data)
|
||||
func (s *fiberServer) netFeesAPI(c *fiber.Ctx) error {
|
||||
moneroRepo := monero.New()
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
|
@ -321,8 +374,8 @@ func NetFees(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// Returns list of countries (count by nodes)
|
||||
func Countries(c *fiber.Ctx) error {
|
||||
// Returns list of countries, count by nodes (API endpoint, JSON data)
|
||||
func (s *fiberServer) countriesAPI(c *fiber.Ctx) error {
|
||||
moneroRepo := monero.New()
|
||||
countries, err := moneroRepo.Countries()
|
||||
if err != nil {
|
||||
|
@ -339,10 +392,10 @@ func Countries(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// Returns node to be probed by the client (prober)
|
||||
// Returns node to be probed by the prober (API endpoint, JSON data)
|
||||
//
|
||||
// This handler should protected by `CheckProber` middleware.
|
||||
func GiveJob(c *fiber.Ctx) error {
|
||||
// This handler should protected by `s.checkProberMW` middleware.
|
||||
func (s *fiberServer) giveJobAPI(c *fiber.Ctx) error {
|
||||
acceptTor := c.QueryInt("accept_tor", 0)
|
||||
acceptIPv6 := c.QueryInt("accept_ipv6", 0)
|
||||
|
||||
|
@ -363,10 +416,10 @@ func GiveJob(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// Handles probe report submission by the prober
|
||||
// Handles probe report submission by the prober (API endpoint, JSON data)
|
||||
//
|
||||
// This handler should protected by `CheckProber` middleware.
|
||||
func ProcessJob(c *fiber.Ctx) error {
|
||||
func (s *fiberServer) processJobAPI(c *fiber.Ctx) error {
|
||||
var report monero.ProbeReport
|
||||
|
||||
if err := c.BodyParser(&report); err != nil {
|
||||
|
|
|
@ -7,18 +7,22 @@ func (s *fiberServer) Routes() {
|
|||
s.App.Get("/add-node", s.addNodeHandler)
|
||||
s.App.Put("/add-node", s.addNodeHandler)
|
||||
|
||||
// This is temporary route to redirect old path to new one. Once search
|
||||
// engine results updated to the new path, this route should be removed.
|
||||
s.App.Get("/remote-nodes/logs", s.redirectLogs)
|
||||
|
||||
// V1 API routes
|
||||
v1 := s.App.Group("/api/v1")
|
||||
|
||||
// these routes are public, they don't require a prober api key
|
||||
v1.Get("/nodes", Nodes)
|
||||
v1.Post("/nodes", AddNode) // old add node form action endpoint. Deprecated: Use PUT /add-node instead
|
||||
v1.Get("/nodes/id/:id", Node)
|
||||
v1.Get("/nodes/logs", ProbeLogs)
|
||||
v1.Get("/fees", NetFees)
|
||||
v1.Get("/countries", Countries)
|
||||
v1.Get("/nodes", s.nodesAPI)
|
||||
v1.Post("/nodes", s.addNodeAPI) // old add node form action endpoint. Deprecated: Use PUT /add-node instead
|
||||
v1.Get("/nodes/id/:id", s.nodeAPI)
|
||||
v1.Get("/nodes/logs", s.probeLogsAPI)
|
||||
v1.Get("/fees", s.netFeesAPI)
|
||||
v1.Get("/countries", s.countriesAPI)
|
||||
|
||||
// these routes are for prober, they require a prober api key
|
||||
v1.Get("/job", CheckProber, GiveJob)
|
||||
v1.Post("/job", CheckProber, ProcessJob)
|
||||
v1.Get("/job", s.checkProberMW, s.giveJobAPI)
|
||||
v1.Post("/job", s.checkProberMW, s.processJobAPI)
|
||||
}
|
||||
|
|
|
@ -8,7 +8,8 @@ import (
|
|||
|
||||
type fiberServer struct {
|
||||
*fiber.App
|
||||
db *database.DB
|
||||
db *database.DB
|
||||
url string
|
||||
}
|
||||
|
||||
// NewServer returns a new fiber server
|
||||
|
@ -22,7 +23,8 @@ func NewServer() *fiberServer {
|
|||
ProxyHeader: config.AppCfg().ProxyHeader,
|
||||
AppName: "XMR Nodes Aggregator " + config.Version,
|
||||
}),
|
||||
db: database.GetDB(),
|
||||
db: database.GetDB(),
|
||||
url: config.AppCfg().URL,
|
||||
}
|
||||
|
||||
return server
|
||||
|
|
|
@ -66,7 +66,7 @@ templ AddNode() {
|
|||
<div id="form-result" class="max-w-4xl mx-auto my-6"></div>
|
||||
<div class="mt-3 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-neutral-500">
|
||||
Existing remote nodes can be found in <a href="/remote-nodes">/remote-nodes</a> page.
|
||||
Existing remote nodes can be found in <a href="/remote-nodes" class="link">/remote-nodes</a> page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -37,7 +37,7 @@ func AddNode() templ.Component {
|
|||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"relative z-10\"><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-10 lg:py-16\"><div class=\"text-center\"><!-- Title --><div class=\"mt-5\"><h1 class=\"block font-extrabold text-4xl md:text-5xl lg:text-6xl text-neutral-200\">Add Monero Node</h1></div><!-- End Title --><div class=\"mt-5\"><p class=\"text-lg text-neutral-300\">You can use this page to add known remote node to the system so my bots can monitor it.</p></div></div><hr class=\"my-6 border-orange-400 mx-auto max-w-3xl\"><div class=\"max-w-4xl mx-auto px-4\"><div class=\"p-4 bg-blue-800/10 border border-blue-900 text-sm text-white rounded-lg\" role=\"alert\" tabindex=\"-1\" aria-labelledby=\"add-node-notice\"><div class=\"flex\"><div class=\"ms-4\"><h2 id=\"add-node-notice\" class=\"text-xl font-bold text-center\">Important Note</h2><div class=\"mt-2 text-sm\"><ul class=\"list-disc space-y-1 ps-5\"><li>As an administrator of this instance, I have full rights to delete, and blacklist any submitted node with or without providing any reason.</li></ul></div></div></div></div></div><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6\"><p class=\"mt-1 text-center\">Enter your Monero node information below (IPv6 host check is experimental):</p><div class=\"mt-12\"><form method=\"put\" hx-swap=\"transition:true\" hx-target=\"#form-result\" hx-disabled-elt=\".form\" hx-on::after-request=\"this.reset()\"><div class=\"grid grid-cols-1 sm:grid-cols-4 gap-6\"><div><label for=\"protocol\" class=\"block text-neutral-200\">Protocol *</label> <select id=\"protocol\" name=\"protocol\" class=\"frameless form\" autocomplete=\"off\"><option value=\"http\">HTTP</option> <option value=\"https\">HTTPS</option></select></div><div class=\"md:col-span-2\"><label for=\"hostname\" class=\"block text-neutral-200\">Host / IP *</label> <input type=\"text\" name=\"hostname\" id=\"hostname\" class=\"frameless form\" autocomplete=\"off\" placeholder=\"Eg: node.example.com or 172.16.17.18\" required></div><div><label for=\"port\" class=\"block text-neutral-200\">Port *</label> <input type=\"text\" name=\"port\" id=\"port\" class=\"frameless form\" autocomplete=\"off\" placeholder=\"Eg: 18081\" required></div></div><div class=\"mt-6 grid\"><button type=\"submit\" class=\"form w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-bold rounded-lg border border-transparent bg-orange-600 text-white hover:bg-orange-500 focus:outline-none disabled:opacity-60 disabled:pointer-events-none\">Submit</button></div></form><div id=\"form-result\" class=\"max-w-4xl mx-auto my-6\"></div><div class=\"mt-3 text-center\"><p class=\"text-sm text-gray-500 dark:text-neutral-500\">Existing remote nodes can be found in <a href=\"/remote-nodes\">/remote-nodes</a> page.</p></div></div></div></div></div></section><!-- End Hero -->")
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"relative z-10\"><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-10 lg:py-16\"><div class=\"text-center\"><!-- Title --><div class=\"mt-5\"><h1 class=\"block font-extrabold text-4xl md:text-5xl lg:text-6xl text-neutral-200\">Add Monero Node</h1></div><!-- End Title --><div class=\"mt-5\"><p class=\"text-lg text-neutral-300\">You can use this page to add known remote node to the system so my bots can monitor it.</p></div></div><hr class=\"my-6 border-orange-400 mx-auto max-w-3xl\"><div class=\"max-w-4xl mx-auto px-4\"><div class=\"p-4 bg-blue-800/10 border border-blue-900 text-sm text-white rounded-lg\" role=\"alert\" tabindex=\"-1\" aria-labelledby=\"add-node-notice\"><div class=\"flex\"><div class=\"ms-4\"><h2 id=\"add-node-notice\" class=\"text-xl font-bold text-center\">Important Note</h2><div class=\"mt-2 text-sm\"><ul class=\"list-disc space-y-1 ps-5\"><li>As an administrator of this instance, I have full rights to delete, and blacklist any submitted node with or without providing any reason.</li></ul></div></div></div></div></div><div class=\"max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6\"><p class=\"mt-1 text-center\">Enter your Monero node information below (IPv6 host check is experimental):</p><div class=\"mt-12\"><form method=\"put\" hx-swap=\"transition:true\" hx-target=\"#form-result\" hx-disabled-elt=\".form\" hx-on::after-request=\"this.reset()\"><div class=\"grid grid-cols-1 sm:grid-cols-4 gap-6\"><div><label for=\"protocol\" class=\"block text-neutral-200\">Protocol *</label> <select id=\"protocol\" name=\"protocol\" class=\"frameless form\" autocomplete=\"off\"><option value=\"http\">HTTP</option> <option value=\"https\">HTTPS</option></select></div><div class=\"md:col-span-2\"><label for=\"hostname\" class=\"block text-neutral-200\">Host / IP *</label> <input type=\"text\" name=\"hostname\" id=\"hostname\" class=\"frameless form\" autocomplete=\"off\" placeholder=\"Eg: node.example.com or 172.16.17.18\" required></div><div><label for=\"port\" class=\"block text-neutral-200\">Port *</label> <input type=\"text\" name=\"port\" id=\"port\" class=\"frameless form\" autocomplete=\"off\" placeholder=\"Eg: 18081\" required></div></div><div class=\"mt-6 grid\"><button type=\"submit\" class=\"form w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-bold rounded-lg border border-transparent bg-orange-600 text-white hover:bg-orange-500 focus:outline-none disabled:opacity-60 disabled:pointer-events-none\">Submit</button></div></form><div id=\"form-result\" class=\"max-w-4xl mx-auto my-6\"></div><div class=\"mt-3 text-center\"><p class=\"text-sm text-gray-500 dark:text-neutral-500\">Existing remote nodes can be found in <a href=\"/remote-nodes\" class=\"link\">/remote-nodes</a> page.</p></div></div></div></div></div></section><!-- End Hero -->")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"github.com/ditatompel/xmr-remote-nodes/internal/ip"
|
||||
"github.com/ditatompel/xmr-remote-nodes/internal/monero"
|
||||
"github.com/ditatompel/xmr-remote-nodes/internal/paging"
|
||||
"github.com/ditatompel/xmr-remote-nodes/utils"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
@ -38,20 +39,43 @@ templ RemoteNodes(data monero.Nodes, countries []monero.Countries, q monero.Quer
|
|||
<div class="mt-5">
|
||||
<p class="text-lg text-neutral-300"><strong>Monero remote node</strong> 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.</p>
|
||||
</div>
|
||||
<hr class="mt-6"/>
|
||||
</div>
|
||||
<div class="max-w-3xl text-center mx-auto mt-8 prose prose-invert">
|
||||
<p>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.</p>
|
||||
<p>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 <strong>Monero community suggests to <span class="font-extrabold text-2xl underline decoration-double decoration-2 decoration-pink-500">always run and use your own node</span></strong> to obtain the maximum possible privacy and to help decentralize the network.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- End Hero -->
|
||||
<div class="flex flex-col max-w-6xl mx-auto mb-10">
|
||||
<section class="flex flex-col max-w-6xl mx-auto mb-10">
|
||||
<div class="min-w-full inline-block align-middle">
|
||||
@TableNodes(data, countries, q, p)
|
||||
</div>
|
||||
</section>
|
||||
<section id="page-info" class="max-w-4xl mx-auto px-4 mb-10">
|
||||
<div class="p-4 bg-blue-800/10 border border-blue-900 text-sm text-white rounded-lg" role="alert" tabindex="-1" aria-labelledby="add-node-notice">
|
||||
<div class="flex">
|
||||
<div class="ms-4">
|
||||
<h2 id="add-node-notice" class="text-xl font-bold text-center">Info</h2>
|
||||
<div class="mt-2 text-sm">
|
||||
<ul class="list-disc space-y-1 ps-5">
|
||||
<li>If you find any remote nodes that are strange or suspicious, please <a href="https://github.com/ditatompel/xmr-remote-nodes/issues" target="_blank" rel="noopener" class="external">open an issue on GitHub</a> for removal.</li>
|
||||
<li>Uptime percentage calculated is the <strong>last 1 month</strong> uptime.</li>
|
||||
<li><strong>Est. Fee</strong> here is just fee estimation / byte from <code class="code text-green-500 font-bold">get_fee_estimate</code> RPC call method.</li>
|
||||
<li>Malicious actors who running remote nodes <a href="/assets/img/node-tx-fee.jpg" rel="noopener" class="link" hx-boost="false">still can return high fee only if you about to create a transactions</a>.</li>
|
||||
<li><strong class="font-extrabold text-2xl underline decoration-double decoration-2 decoration-pink-500">The best and safest way is running your own node!</strong></li>
|
||||
<li>Nodes with 0% uptime within 1 month with more than 300 check attempt will be removed. You can always add your node again latter.</li>
|
||||
<li>You can filter remote node by selecting on <strong>nettype</strong>, <strong>protocol</strong>, <strong>country</strong>, <strong>tor</strong>, and <strong>online status</strong> option.</li>
|
||||
<li>If you want to add more remote node, you can add them using <a href="/add-node" class="link">/add-node</a> page.</li>
|
||||
<li>I deliberately cut the long Tor addresses, click the <span class="text-orange-300">👁 torhostname...</span> to see the full Tor address.</li>
|
||||
<li>You can found larger remote nodes database from <a href="https://monero.fail/" target="_blank" rel="noopener" class="external">monero.fail</a>.</li>
|
||||
<li>If you are developer or power user who like to fetch Monero remote node above in JSON format, you can read <a href="https://insights.ditatompel.com/en/blog/2022/01/public-api-monero-remote-node-list/" class="external">Public API Monero Remote Node List</a> blog post for more detailed information.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="max-w-4xl text-center mx-auto my-10 prose prose-invert">
|
||||
<p>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.</p>
|
||||
<p>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 <strong>Monero community suggests to <span class="font-extrabold underline decoration-double decoration-2 decoration-pink-500">always run and use your own node</span></strong> to obtain the maximum possible privacy and to help decentralize the network.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
@ -210,8 +234,10 @@ templ TableNodes(data monero.Nodes, countries []monero.Countries, q monero.Query
|
|||
<td class="text-right">{ fmt.Sprintf("%d", row.EstimateFee) }</td>
|
||||
<td class="text-right">
|
||||
@cellUptime(row.Uptime)
|
||||
<br/>
|
||||
<a href={ templ.URL(fmt.Sprintf("/remote-nodes/id/%d", row.ID)) } class="link">[Logs]</a>
|
||||
</td>
|
||||
<td title={ time.Unix(row.LastChecked, 0).UTC().Format("Jan 2, 2006 15:04 MST") }>{ timeSince(row.LastChecked) }</td>
|
||||
<td title={ time.Unix(row.LastChecked, 0).UTC().Format("Jan 2, 2006 15:04 MST") }>{ utils.TimeSince(row.LastChecked) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
@ -225,13 +251,182 @@ templ TableNodes(data monero.Nodes, countries []monero.Countries, q monero.Query
|
|||
}
|
||||
|
||||
templ Node(data monero.Node) {
|
||||
<p>{ fmt.Sprintf("%s:%d", data.Hostname, data.Port) }</p>
|
||||
<div class="space-y-3 text-neutral-200">
|
||||
<dl class="flex flex-col sm:flex-row gap-1">
|
||||
<dt class="min-w-40">
|
||||
<span class="block text-white text-bold">Host:</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
<li class="me-1 inline-flex items-center">
|
||||
{ fmt.Sprintf("%s:%d", data.Hostname, data.Port) }
|
||||
</li>
|
||||
<li class="me-1 inline-flex items-center">
|
||||
<button type="button" class="clipboard px-2 inline-flex items-center gap-x-2 text-sm font-bold rounded-lg border border-transparent bg-orange-600 text-white hover:bg-orange-500 focus:outline-none disabled:opacity-60 disabled:pointer-events-none" data-clipboard-text={ fmt.Sprintf("%s:%d", data.Hostname, data.Port) }>Copy</button>
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
<dl class="flex flex-col sm:flex-row gap-1">
|
||||
<dt class="min-w-40">
|
||||
<span class="block text-white text-bold">Net Type:</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
<li class="uppercase">{ data.Nettype }</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
<dl class="flex flex-col sm:flex-row gap-1">
|
||||
<dt class="min-w-40">
|
||||
<span class="block text-white text-bold">Protocol:</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
<li class="uppercase">{ data.Protocol }</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
<dl class="flex flex-col sm:flex-row gap-1">
|
||||
<dt class="min-w-40">
|
||||
<span class="block text-white text-bold">IP Addresses:</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
<li class="whitespace-break-spaces">{ strings.ReplaceAll(data.IPAddresses, ",", ", ") }</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ NodeDetails(data monero.Node, logs monero.FetchLogs, q monero.QueryLogs, p paging.Pagination) {
|
||||
<section class="relative overflow-hidden pt-6">
|
||||
@heroGradient()
|
||||
<div class="relative z-10">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-10 lg:py-16">
|
||||
<div class="text-center">
|
||||
<!-- Title -->
|
||||
<div class="mt-5">
|
||||
<h1 class="block font-extrabold text-4xl md:text-5xl lg:text-6xl text-neutral-200">
|
||||
Monero Node #{ fmt.Sprintf("%d", data.ID) }
|
||||
</h1>
|
||||
</div>
|
||||
<hr class="mt-6"/>
|
||||
</div>
|
||||
<div class="max-w-3xl mx-auto mt-8">
|
||||
@Node(data)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- End Hero -->
|
||||
<div class="flex flex-col max-w-6xl mx-auto mb-10">
|
||||
<div class="my-6 text-center">
|
||||
<div class="mt-5">
|
||||
<h2 class="block font-extrabold text-4xl md:text-4xl lg:text-5xl text-neutral-200">Probe Logs</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-full inline-block align-middle">
|
||||
@TableLogs(fmt.Sprintf("/remote-nodes/id/%d", data.ID), logs, q, p)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TableLogs(hxPath string, data monero.FetchLogs, q monero.QueryLogs, p paging.Pagination) {
|
||||
<div id="tbl_logs" class="bg-neutral-800 border border-neutral-700 rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-b border-neutral-700">
|
||||
@DtRowPerPage(hxPath, "#tbl_logs", q.Limit, q)
|
||||
<div>
|
||||
@DtRefreshInterval(hxPath, "#tbl_logs", q.Refresh, q)
|
||||
</div>
|
||||
@DtReload(hxPath, "#tbl_logs", q)
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="dt">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#ID</th>
|
||||
<th scope="col">Prober ID</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Height</th>
|
||||
<th scope="col">Adjusted Time</th>
|
||||
<th scope="col">DB Size</th>
|
||||
<th scope="col">Difficulty</th>
|
||||
@DtThSort(hxPath, "#tbl_logs", "Est. Fee", "estimate_fee", q.SortBy, q.SortDirection, q)
|
||||
@DtThSort(hxPath, "#tbl_logs", "Check", "date_checked", q.SortBy, q.SortDirection, q)
|
||||
@DtThSort(hxPath, "#tbl_logs", "Runtime", "fetch_runtime", q.SortBy, q.SortDirection, q)
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
class="frameless"
|
||||
autocomplete="off"
|
||||
hx-get={ fmt.Sprintf("%s?%s", hxPath, paging.EncodedQuery(q, []string{"status"})) }
|
||||
hx-trigger="change"
|
||||
hx-push-url="true"
|
||||
hx-target="#tbl_logs"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
for _, status := range nodeStatuses {
|
||||
<option value={ fmt.Sprintf("%d", status.Code) } selected?={ status.Code == q.Status }>{ status.Text }</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td colspan="7">
|
||||
<input
|
||||
type="text"
|
||||
id="failed_reason"
|
||||
name="failed_reason"
|
||||
value={ fmt.Sprintf("%s", q.FailedReason) }
|
||||
autocomplete="off"
|
||||
class="frameless"
|
||||
placeholder="Filter reason"
|
||||
hx-get={ fmt.Sprintf("%s?%s", hxPath, paging.EncodedQuery(q, []string{"failed_reason"})) }
|
||||
hx-push-url="true"
|
||||
hx-trigger="keyup changed delay:0.4s"
|
||||
hx-target="#tbl_logs"
|
||||
hx-swap="outerHTML"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, row := range data.Items {
|
||||
<tr>
|
||||
<td>{ fmt.Sprintf("%d", row.ID) }</td>
|
||||
<td>{ fmt.Sprintf("%d", row.ProberID) }</td>
|
||||
if row.Status == 1 {
|
||||
<td class="text-green-500">OK</td>
|
||||
<td class="text-right">{ fmt.Sprintf("%d", row.Height) }</td>
|
||||
<td>{ time.Unix(row.AdjustedTime, 0).UTC().Format("Jan 2, 2006 15:04 MST") }</td>
|
||||
<td>{ utils.FormatBytes(row.DatabaseSize, 0) }</td>
|
||||
<td>{ utils.FormatHashes(float64(row.Difficulty)) }</td>
|
||||
<td class="text-right">{ fmt.Sprintf("%d", row.EstimateFee) }</td>
|
||||
} else {
|
||||
<td class="text-red-500">ERR</td>
|
||||
<td colspan="5">{ row.FailedReason }</td>
|
||||
}
|
||||
<td title={ time.Unix(row.DateChecked, 0).UTC().Format("Jan 2, 2006 15:04 MST") }>{ utils.TimeSince(row.DateChecked) }</td>
|
||||
<td class="text-right">{ utils.FormatFloat(row.FetchRuntime) }s</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-t border-neutral-700">
|
||||
@DtRowCount(p.CurrentPage, data.RowsPerPage, data.TotalRows)
|
||||
@DtPagination(hxPath, "#tbl_logs", q, p)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ cellHostPort(id, port uint, hostname, ips string, isTor, ipv6Only bool) {
|
||||
if isTor {
|
||||
<button
|
||||
class="max-w-40 truncate text-orange-400"
|
||||
class="max-w-40 truncate text-orange-400 hover:brightness-125"
|
||||
hx-get={ fmt.Sprintf("/remote-nodes/id/%d", id) }
|
||||
hx-push-url="false"
|
||||
hx-target="#modal-section"
|
||||
|
@ -323,12 +518,12 @@ templ cellStatuses(isAvailable bool, statuses [5]int) {
|
|||
|
||||
templ cellUptime(uptime float64) {
|
||||
if uptime >= 98 {
|
||||
<span class="text-green-500">{ formatFloat(uptime) }%</span>
|
||||
<span class="text-green-500">{ utils.FormatFloat(uptime) }%</span>
|
||||
} else if uptime < 98 && uptime >= 80 {
|
||||
<span class="text-sky-500">{ formatFloat(uptime) }%</span>
|
||||
<span class="text-sky-500">{ utils.FormatFloat(uptime) }%</span>
|
||||
} else if uptime < 80 && uptime > 75 {
|
||||
<span class="text-orange-500">{ formatFloat(uptime) }%</span>
|
||||
<span class="text-orange-500">{ utils.FormatFloat(uptime) }%</span>
|
||||
} else {
|
||||
<span class="text-rose-500">{ formatFloat(uptime) }%</span>
|
||||
<span class="text-rose-500">{ utils.FormatFloat(uptime) }%</span>
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,47 +0,0 @@
|
|||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Convert the float to a string, trimming unnecessary zeros
|
||||
func formatFloat(f float64) string {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// TimeSince converts an int64 timestamp to a relative time string
|
||||
func timeSince(timestamp int64) string {
|
||||
var duration time.Duration
|
||||
var suffix string
|
||||
|
||||
t := time.Unix(timestamp, 0)
|
||||
|
||||
if t.After(time.Now()) {
|
||||
duration = time.Until(t)
|
||||
suffix = "from now"
|
||||
} else {
|
||||
duration = time.Since(t)
|
||||
suffix = "ago"
|
||||
}
|
||||
|
||||
switch {
|
||||
case duration < time.Minute:
|
||||
return fmt.Sprintf("%ds %s", int(duration.Seconds()), suffix)
|
||||
case duration < time.Hour:
|
||||
return fmt.Sprintf("%dm %s", int(duration.Minutes()), suffix)
|
||||
case duration < time.Hour*24:
|
||||
return fmt.Sprintf("%dh %s", int(duration.Hours()), suffix)
|
||||
case duration < time.Hour*24*7:
|
||||
return fmt.Sprintf("%dd %s", int(duration.Hours()/24), suffix)
|
||||
case duration < time.Hour*24*30:
|
||||
return fmt.Sprintf("%dw %s", int(duration.Hours()/(24*7)), suffix)
|
||||
default:
|
||||
months := int(duration.Hours() / (24 * 30))
|
||||
if months == 1 {
|
||||
return fmt.Sprintf("1 month %s", suffix)
|
||||
}
|
||||
return fmt.Sprintf("%d months %s", months, suffix)
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ import (
|
|||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -221,8 +222,8 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
|
|||
ipAddr = hostIp.String()
|
||||
ips = ip.SliceToString(hostIps)
|
||||
} else {
|
||||
if strings.HasPrefix(hostname, "http://") || strings.HasPrefix(hostname, "https://") {
|
||||
return errors.New("Don't start hostname with http:// or https://, just put your hostname")
|
||||
if !validTorHostname(hostname) {
|
||||
return errors.New("Invalid TOR v3 .onion hostname")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -295,6 +296,14 @@ func (r *moneroRepo) Add(protocol string, hostname string, port uint) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// validTorHostname shecks if a given hostname is a valid TOR v3 .onion address
|
||||
// with optional subdomain
|
||||
//
|
||||
// TOR v3 .onion addresses are 56 characters of `base32` followed by ".onion"
|
||||
func validTorHostname(hostname string) bool {
|
||||
return regexp.MustCompile(`^([a-z0-9-]+\.)*[a-z2-7]{56}\.onion$`).MatchString(hostname)
|
||||
}
|
||||
|
||||
func (r *moneroRepo) Delete(id uint) error {
|
||||
if _, err := r.db.Exec(`DELETE FROM tbl_node WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
|
|
|
@ -131,6 +131,52 @@ func Benchmark_QueryNodes_toSQL(b *testing.B) {
|
|||
}
|
||||
}
|
||||
|
||||
// Single test:
|
||||
// go test -race ./internal/monero -run=TestValidTorHostname -v
|
||||
func TestValidTorHostname(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "Empty host",
|
||||
host: "",
|
||||
wantValid: false,
|
||||
},
|
||||
{
|
||||
name: "Valid tor host",
|
||||
host: "cakexmrl7bonq7ovjka5kuwuyd3f7qnkz6z6s6dmsy3uckwra7bvggyd.onion",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "Valid tor host with subdomain",
|
||||
host: "just-test.cakexmrl7bonq7ovjka5kuwuyd3f7qnkz6z6s6dmsy3uckwra7bvggyd.onion",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid host",
|
||||
host: "test.com",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if r := validTorHostname(tt.host); r != tt.wantValid {
|
||||
t.Errorf("ValidTorHostname() error = %v, wantValid %v", r, tt.wantValid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Single bench test:
|
||||
// go test ./internal/monero -bench validTorHostname -benchmem -run=^$ -v
|
||||
func Benchmark_validTorHostname(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validTorHostname("cakexmrl7bonq7ovjka5kuwuyd3f7qnkz6z6s6dmsy3uckwra7bvggyd.onion")
|
||||
}
|
||||
}
|
||||
|
||||
// equalArgs is helper function for testing.
|
||||
//
|
||||
// This returns true if two slices of interface{} are equal.
|
||||
|
|
|
@ -11,17 +11,14 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/ditatompel/xmr-remote-nodes/internal/ip/geo"
|
||||
"github.com/ditatompel/xmr-remote-nodes/internal/paging"
|
||||
)
|
||||
|
||||
type QueryLogs struct {
|
||||
NodeID int // 0 for all, >0 for specific node
|
||||
Status int // -1 for all, 0 for failed, 1 for success
|
||||
FailedReason string // empty for all, if not empty, will be used as search from failed_reaso
|
||||
|
||||
RowsPerPage int
|
||||
Page int
|
||||
SortBy string
|
||||
SortDirection string
|
||||
paging.Paging
|
||||
NodeID int `url:"node_id,omitempty"` // 0 for all, >0 for specific node
|
||||
Status int `url:"status"` // -1 for all, 0 for failed, 1 for success
|
||||
FailedReason string `url:"failed_reason,omitempty"` // empty for all, non empty string will be used as search
|
||||
}
|
||||
|
||||
func (q QueryLogs) toSQL() (args []interface{}, where, sortBy, sortDirection string) {
|
||||
|
@ -62,17 +59,18 @@ type FetchLog struct {
|
|||
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"`
|
||||
AdjustedTime int64 `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"`
|
||||
DateChecked int64 `db:"date_checked" json:"date_checked"`
|
||||
FailedReason string `db:"failed_reason" json:"failed_reason"`
|
||||
FetchRuntime float64 `db:"fetch_runtime" json:"fetch_runtime"`
|
||||
}
|
||||
|
||||
type FetchLogs struct {
|
||||
TotalRows int `json:"total_rows"`
|
||||
TotalPages int `json:"total_pages"` // total pages
|
||||
RowsPerPage int `json:"rows_per_page"`
|
||||
Items []*FetchLog `json:"items"`
|
||||
}
|
||||
|
@ -82,15 +80,18 @@ func (r *moneroRepo) Logs(q QueryLogs) (FetchLogs, error) {
|
|||
args, where, sortBy, sortDirection := q.toSQL()
|
||||
|
||||
var fetchLogs FetchLogs
|
||||
fetchLogs.RowsPerPage = q.RowsPerPage
|
||||
fetchLogs.RowsPerPage = q.Limit
|
||||
|
||||
qTotal := fmt.Sprintf(`SELECT COUNT(id) FROM tbl_probe_log %s`, where)
|
||||
err := r.db.QueryRow(qTotal, args...).Scan(&fetchLogs.TotalRows)
|
||||
if err != nil {
|
||||
return fetchLogs, err
|
||||
}
|
||||
args = append(args, q.RowsPerPage, (q.Page-1)*q.RowsPerPage)
|
||||
|
||||
fetchLogs.TotalPages = int(math.Ceil(float64(fetchLogs.TotalRows) / float64(q.Limit)))
|
||||
args = append(args, q.Limit, (q.Page-1)*q.Limit)
|
||||
|
||||
fmt.Printf("%+v", fetchLogs)
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
*
|
||||
|
|
|
@ -3,6 +3,7 @@ package monero
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ditatompel/xmr-remote-nodes/internal/paging"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
)
|
||||
|
||||
|
@ -64,13 +65,15 @@ func TestQueryLogs_toSQL(t *testing.T) {
|
|||
{
|
||||
name: "Default query",
|
||||
fields: QueryLogs{
|
||||
NodeID: 0,
|
||||
Status: -1,
|
||||
FailedReason: "",
|
||||
RowsPerPage: 10,
|
||||
Page: 1,
|
||||
SortBy: "date_checked",
|
||||
SortDirection: "desc",
|
||||
Paging: paging.Paging{
|
||||
Limit: 10,
|
||||
Page: 1,
|
||||
SortBy: "date_checked",
|
||||
SortDirection: "desc",
|
||||
},
|
||||
NodeID: 0,
|
||||
Status: -1,
|
||||
FailedReason: "",
|
||||
},
|
||||
wantArgs: []interface{}{},
|
||||
wantWhere: "",
|
||||
|
@ -80,13 +83,15 @@ func TestQueryLogs_toSQL(t *testing.T) {
|
|||
{
|
||||
name: "With node_id query",
|
||||
fields: QueryLogs{
|
||||
NodeID: 1,
|
||||
Status: -1,
|
||||
FailedReason: "",
|
||||
RowsPerPage: 10,
|
||||
Page: 1,
|
||||
SortBy: "date_checked",
|
||||
SortDirection: "desc",
|
||||
Paging: paging.Paging{
|
||||
Limit: 10,
|
||||
Page: 1,
|
||||
SortBy: "date_checked",
|
||||
SortDirection: "desc",
|
||||
},
|
||||
NodeID: 1,
|
||||
Status: -1,
|
||||
FailedReason: "",
|
||||
},
|
||||
wantArgs: []interface{}{1},
|
||||
wantWhere: "WHERE node_id = ?",
|
||||
|
@ -96,13 +101,15 @@ func TestQueryLogs_toSQL(t *testing.T) {
|
|||
{
|
||||
name: "All possible query",
|
||||
fields: QueryLogs{
|
||||
NodeID: 1,
|
||||
Status: 0,
|
||||
FailedReason: "test",
|
||||
RowsPerPage: 10,
|
||||
Page: 1,
|
||||
SortBy: "date_checked",
|
||||
SortDirection: "asc",
|
||||
Paging: paging.Paging{
|
||||
Limit: 10,
|
||||
Page: 1,
|
||||
SortBy: "date_checked",
|
||||
SortDirection: "asc",
|
||||
},
|
||||
NodeID: 1,
|
||||
Status: 0,
|
||||
FailedReason: "test",
|
||||
},
|
||||
wantArgs: []interface{}{1, 0, "%test%"},
|
||||
wantWhere: "WHERE node_id = ? AND is_available = ? AND failed_reason LIKE ?",
|
||||
|
@ -113,13 +120,15 @@ func TestQueryLogs_toSQL(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
q := QueryLogs{
|
||||
NodeID: tt.fields.NodeID,
|
||||
Status: tt.fields.Status,
|
||||
FailedReason: tt.fields.FailedReason,
|
||||
RowsPerPage: tt.fields.RowsPerPage,
|
||||
Page: tt.fields.Page,
|
||||
SortBy: tt.fields.SortBy,
|
||||
SortDirection: tt.fields.SortDirection,
|
||||
Paging: paging.Paging{
|
||||
Limit: tt.fields.Limit,
|
||||
Page: tt.fields.Page,
|
||||
SortBy: tt.fields.SortBy,
|
||||
SortDirection: tt.fields.SortDirection,
|
||||
},
|
||||
NodeID: tt.fields.NodeID,
|
||||
Status: tt.fields.Status,
|
||||
FailedReason: tt.fields.FailedReason,
|
||||
}
|
||||
gotArgs, gotWhere, gotSortBy, gotSortDirection := q.toSQL()
|
||||
if !equalArgs(gotArgs, tt.wantArgs) {
|
||||
|
|
108
utils/human_readable.go
Normal file
108
utils/human_readable.go
Normal file
|
@ -0,0 +1,108 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeSince converts an int64 timestamp to a relative time string
|
||||
func TimeSince(timestamp int64) string {
|
||||
var duration time.Duration
|
||||
var suffix string
|
||||
|
||||
t := time.Unix(timestamp, 0)
|
||||
|
||||
if t.After(time.Now()) {
|
||||
duration = time.Until(t)
|
||||
suffix = "from now"
|
||||
} else {
|
||||
duration = time.Since(t)
|
||||
suffix = "ago"
|
||||
}
|
||||
|
||||
switch {
|
||||
case duration < time.Minute:
|
||||
return fmt.Sprintf("%ds %s", int(duration.Seconds()), suffix)
|
||||
case duration < time.Hour:
|
||||
return fmt.Sprintf("%dm %s", int(duration.Minutes()), suffix)
|
||||
case duration < time.Hour*24:
|
||||
return fmt.Sprintf("%dh %s", int(duration.Hours()), suffix)
|
||||
case duration < time.Hour*24*7:
|
||||
return fmt.Sprintf("%dd %s", int(duration.Hours()/24), suffix)
|
||||
case duration < time.Hour*24*30:
|
||||
return fmt.Sprintf("%dw %s", int(duration.Hours()/(24*7)), suffix)
|
||||
default:
|
||||
months := int(duration.Hours() / (24 * 30))
|
||||
if months == 1 {
|
||||
return fmt.Sprintf("1 month %s", suffix)
|
||||
}
|
||||
return fmt.Sprintf("%d months %s", months, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the float to a string, trimming unnecessary zeros
|
||||
func FormatFloat(f float64) string {
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Formats bytes as a human-readable string with the specified number of decimal places.
|
||||
func FormatBytes(bytes, decimals int) string {
|
||||
if bytes == 0 {
|
||||
return "0 Bytes"
|
||||
}
|
||||
|
||||
const k float64 = 1024
|
||||
sizes := []string{"Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"}
|
||||
|
||||
i := int(math.Floor(math.Log(float64(bytes)) / math.Log(k)))
|
||||
dm := decimals
|
||||
if dm < 0 {
|
||||
dm = 0
|
||||
}
|
||||
|
||||
value := float64(bytes) / math.Pow(k, float64(i))
|
||||
return fmt.Sprintf("%.*f %s", dm, value, sizes[i])
|
||||
}
|
||||
|
||||
// Formats a hash value (h) into human readable format.
|
||||
//
|
||||
// This function was adapted from jtgrassie/monero-pool project.
|
||||
// Source: https://github.com/jtgrassie/monero-pool/blob/master/src/webui-embed.html
|
||||
//
|
||||
// Copyright (c) 2018, The Monero Project
|
||||
func FormatHashes(h float64) string {
|
||||
switch {
|
||||
case h < 1e-12:
|
||||
return "0 H"
|
||||
case h < 1e-9:
|
||||
return fmt.Sprintf("%.0f pH", maxPrecision(h*1e12, 0))
|
||||
case h < 1e-6:
|
||||
return fmt.Sprintf("%.0f nH", maxPrecision(h*1e9, 0))
|
||||
case h < 1e-3:
|
||||
return fmt.Sprintf("%.0f μH", maxPrecision(h*1e6, 0))
|
||||
case h < 1:
|
||||
return fmt.Sprintf("%.0f mH", maxPrecision(h*1e3, 0))
|
||||
case h < 1e3:
|
||||
return fmt.Sprintf("%.0f H", h)
|
||||
case h < 1e6:
|
||||
return fmt.Sprintf("%.2f KH", maxPrecision(h*1e-3, 2))
|
||||
case h < 1e9:
|
||||
return fmt.Sprintf("%.2f MH", maxPrecision(h*1e-6, 2))
|
||||
default:
|
||||
return fmt.Sprintf("%.2f GH", maxPrecision(h*1e-9, 2))
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a number with a maximum precision.
|
||||
//
|
||||
// This function was adapted from jtgrassie/monero-pool project.
|
||||
// Source: https://github.com/jtgrassie/monero-pool/blob/master/src/webui-embed.html
|
||||
//
|
||||
// Copyright (c) 2018, The Monero Project
|
||||
func maxPrecision(n float64, p int) float64 {
|
||||
format := "%." + strconv.Itoa(p) + "f"
|
||||
result, _ := strconv.ParseFloat(fmt.Sprintf(format, n), 64)
|
||||
return result
|
||||
}
|
Loading…
Reference in a new issue