Simple display probe logs

This commit is contained in:
ditatompel 2024-05-06 17:19:17 +07:00
parent 33aae21237
commit 8f5f972faf
No known key found for this signature in database
GPG key ID: 31D3D06D77950979
8 changed files with 449 additions and 3 deletions

View file

@ -293,8 +293,9 @@
is_tor={row.is_tor} is_tor={row.is_tor}
hostname={row.hostname} hostname={row.hostname}
port={row.port} port={row.port}
/></td />
> <a class="anchor" href="/remote-nodes/logs/?node_id={row.id}">[Logs]</a>
</td>
<td><NetTypeCell nettype={row.nettype} height={row.height} /></td> <td><NetTypeCell nettype={row.nettype} height={row.height} /></td>
<td><ProtocolCell protocol={row.protocol} cors={row.cors} /></td> <td><ProtocolCell protocol={row.protocol} cors={row.cors} /></td>
<td <td

View file

@ -0,0 +1,28 @@
/** @type {import('./$types').PageLoad} */
export async function load({ data }) {
/* prettier-ignore */
const metaDefaults = {
title: 'Probe Logs',
description: 'Monero is private, decentralized cryptocurrency that keeps your finances confidential and secure.',
keywords: 'monero,xmr,monero node,xmrnode,cryptocurrency'
};
return {
meta: {
title: metaDefaults.title,
description: metaDefaults.description,
keywords: metaDefaults.keywords,
image:
'https://vcl-og-img.ditatompel.com/' + encodeURIComponent(metaDefaults.title) + '.png?md=0',
// Article
article: { publishTime: '', modifiedTime: '', author: '' },
// Twitter
twitter: {
title: metaDefaults.title,
description: metaDefaults.description,
image: metaDefaults.image
}
},
};
}

View file

@ -0,0 +1,262 @@
<script>
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();
});
</script>
<header id="hero" class="hero-gradient py-7">
<div class="section-container text-center">
<h1 class="h1 pb-2 font-extrabold">{data.meta.title}</h1>
<p class="mx-auto max-w-3xl">
<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>
<div class="mx-auto w-full max-w-3xl px-20">
<hr class="!border-primary-400-500-token !border-t-4 !border-double" />
</div>
</header>
<section id="introduction ">
<div class="section-container text-center !max-w-4xl">
<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 always run your own node</strong> to
obtain the maximum possible privacy and to help decentralize the network.
</p>
</div>
</section>
<section id="monero-remote-node">
<div class="section-container">
<div class="space-y-2 overflow-x-auto">
<div class="flex justify-between">
<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>#ID</th>
<th><label for="prober_id">Prober</label></th>
<th><label for="status">Status</label></th>
<th>Height</th>
<th>Adjusted Time</th>
<th>DB Size</th>
<th>Difficulty</th>
<DtSrThSort {handler} orderBy="estimate_fee">Est. Fee</DtSrThSort>
<DtSrThSort {handler} orderBy="date_checked">Date Checked</DtSrThSort>
<DtSrThSort {handler} orderBy="fetch_runtime">Runtime</DtSrThSort>
</tr>
<tr>
<th colspan="2">
<select
id="prober_id"
name="prober_id"
class="select variant-form-material"
bind:value={filterProberId}
on:change={() => {
handler.filter(filterProberId, 'prober_id');
handler.invalidate();
}}
>
<option value={0}>Any</option>
</select>
</th>
<th colspan="2">
<select
id="status"
name="status"
class="select variant-form-material"
bind:value={filterStatus}
on:change={() => {
handler.filter(filterStatus, 'status');
handler.invalidate();
}}
>
<option value={-1}>Any</option>
<option value="1">Online</option>
<option value="0">Offline</option>
</select>
</th>
<DtSrThFilter
{handler}
filterBy="failed_reason"
placeholder="Filter reason"
colspan={6}
/>
</tr>
</thead>
<tbody>
{#each $rows as row (row.id)}
<tr>
<td>{row.id}</td>
<td>{row.prober_id}</td>
<td>{row.status === 1 ? 'OK' : 'ERR'}</td>
{#if row.status !== 1}
<td colspan="5">{row.failed_reason ?? ''}</td>
{:else}
<td class="text-right">{row.height.toLocaleString(undefined)}</td>
<td>{format(row.adjusted_time * 1000, 'yyyy-MM-dd HH:mm')}</td>
<td class="text-right">{formatBytes(row.database_size, 2)}</td>
<td class="text-right">{formatHashes(row.difficulty)}</td>
<td class="text-right">{row.estimate_fee.toLocaleString(undefined)}</td>
{/if}
<td>
{format(row.date_checked * 1000, 'PP HH:mm')}<br />
{formatDistance(row.date_checked * 1000, new Date(), { addSuffix: true })}
</td>
<td class="text-right">{parseRuntime(row.fetch_runtime)}</td>
</tr>
{/each}
</tbody>
</table>
<div class="flex justify-between mb-2">
<DtSrRowCount {handler} />
<DtSrPagination {handler} />
</div>
</div>
</div>
</section>
<style lang="postcss">
.section-container {
@apply mx-auto w-full max-w-7xl p-4;
}
/* Hero Gradient */
/* prettier-ignore */
.hero-gradient {
background-image:
radial-gradient(at 0% 0%, rgba(242, 104, 34, .4) 0px, transparent 50%),
radial-gradient(at 98% 1%, rgba(var(--color-warning-900) / 0.33) 0px, transparent 50%);
}
/*
td:nth-child(1) {
@apply max-w-20;
}
*/
</style>

View file

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

View file

@ -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 { func AddNode(c *fiber.Ctx) error {
formPort := c.FormValue("port") formPort := c.FormValue("port")
port, err := strconv.Atoi(formPort) port, err := strconv.Atoi(formPort)

View file

@ -16,6 +16,7 @@ func V1Api(app *fiber.App) {
v1.Post("/prober", Prober) v1.Post("/prober", Prober)
v1.Get("/nodes", MoneroNodes) v1.Get("/nodes", MoneroNodes)
v1.Post("/nodes", AddNode) v1.Post("/nodes", AddNode)
v1.Get("/nodes/logs", ProbeLogs)
v1.Get("/fees", NetFee) v1.Get("/fees", NetFee)
v1.Get("/countries", Countries) v1.Get("/countries", Countries)
v1.Get("/job", CheckProber, GiveJob) v1.Get("/job", CheckProber, GiveJob)

View file

@ -22,6 +22,7 @@ type MoneroRepository interface {
ProcessJob(report ProbeReport, proberId int64) error ProcessJob(report ProbeReport, proberId int64) error
NetFee() []NetFee NetFee() []NetFee
Countries() ([]MoneroCountries, error) Countries() ([]MoneroCountries, error)
Logs(q MoneroLogQueryParams) (MoneroNodeFetchLogs, error)
} }
type MoneroRepo struct { type MoneroRepo struct {
@ -173,6 +174,95 @@ func (repo *MoneroRepo) Nodes(q MoneroQueryParams) (MoneroNodes, error) {
return nodes, nil 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 { func (repo *MoneroRepo) Add(protocol string, hostname string, port uint) error {
if protocol != "http" && protocol != "https" { if protocol != "http" && protocol != "https" {
return errors.New("Invalid protocol, must one of or HTTP/HTTPS") return errors.New("Invalid protocol, must one of or HTTP/HTTPS")

View file

@ -87,7 +87,7 @@ CREATE TABLE `tbl_probe_log` (
`estimate_fee` int(9) unsigned NOT NULL DEFAULT 0, `estimate_fee` int(9) unsigned NOT NULL DEFAULT 0,
`date_checked` bigint(20) unsigned NOT NULL DEFAULT 0, `date_checked` bigint(20) unsigned NOT NULL DEFAULT 0,
`failed_reason` text NOT NULL DEFAULT '', `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`), PRIMARY KEY (`id`),
KEY `node_id` (`node_id`) KEY `node_id` (`node_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;