Compare commits

..

No commits in common. "76c6a5514d301a5aaaefbf5f6586b84f73d887d2" and "68c4b7c9b375fdc26d352510ce7d9fd00dfd607b" have entirely different histories.

387 changed files with 6530 additions and 5406 deletions

View file

@ -7,14 +7,14 @@ tmp_dir = "tmp"
bin = "./tmp/main"
cmd = "make dev"
delay = 0
exclude_dir = ["assets", "tmp", "testdata", "node_modules", "data", "bin", "internal/handler/views/assets"]
exclude_dir = ["assets", "tmp", "testdata", "frontend/node_modules", "data", "bin"]
exclude_file = []
exclude_regex = ["_test.go", ".*_templ.go"]
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "templ", "html", "css", "js"]
include_ext = ["go", "tpl", "tmpl", "html", "css", "js"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"

View file

@ -8,13 +8,10 @@ SERVER_ENDPOINT="http://127.0.0.1:18901"
API_KEY=
ACCEPT_TOR=false
TOR_SOCKS="127.0.0.1:9050"
ACCEPT_I2P=false
I2P_SOCKS="127.0.0.1:4447"
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

View file

@ -10,3 +10,14 @@ updates:
- ditatompel
assignees:
- ditatompel
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: weekly
time: "05:00"
open-pull-requests-limit: 5
reviewers:
- ditatompel
assignees:
- ditatompel

View file

@ -14,20 +14,25 @@ jobs:
- name: Check out source code
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: setup NodeJS
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Cache node modules
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.22.x
- name: Setup templ
run: go install github.com/a-h/templ/cmd/templ@v0.2.778
- name: Prepare assets
run: make prepare
- name: Cache Go modules
uses: actions/cache@v3
with:

View file

@ -16,20 +16,20 @@ jobs:
- name: Check out source code
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: setup NodeJS
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.22.x
- name: Setup templ
run: go install github.com/a-h/templ/cmd/templ@v0.2.778
# Need to build the UI here before build the server binary with go-release-action
- name: Prepare assets
run: make prepare templ tailwind
- name: Build UI
run: make ui
- name: Build server binary
uses: wangyoucao577/go-release-action@v1

View file

@ -2,9 +2,6 @@ on:
push:
branches:
- main
- htmx
- i2p-support
pull_request:
name: Test
jobs:
@ -15,17 +12,11 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.22.x
- name: Setup templ
run: go install github.com/a-h/templ/cmd/templ@v0.2.778
- name: Cache Go modules
uses: actions/cache@v3
with:
@ -34,8 +25,22 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Prepare assets
run: make prepare templ tailwind
- name: setup NodeJS
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Cache node modules
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Build UI
run: make ui
- name: Run lint
uses: golangci/golangci-lint-action@v4

2
.gitignore vendored
View file

@ -3,5 +3,3 @@
/node_modules
/tmp
/assets/geoip
/internal/handler/views/assets/css/**/*
/internal/handler/views/assets/js/**/*

43
LICENSE
View file

@ -1,28 +1,27 @@
Copyright (c) 2024, Christian Ditaputratama
GLWTS(Good Luck With That Shit) Public License
Copyright (c) Every-fucking-one, except the Author
All rights reserved.
Everyone is permitted to copy, distribute, modify, merge, sell, publish,
sublicense or whatever the fuck they want with this software but at their
OWN RISK.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
Preamble
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
The author has absolutely no fucking clue what the code in this project
does. It might just fucking work or not, there is no third option.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
GOOD LUCK WITH THAT SHIT PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION, AND MODIFICATION
0. You just DO WHATEVER THE FUCK YOU WANT TO as long as you NEVER LEAVE
A FUCKING TRACE TO TRACK THE AUTHOR of the original product to blame for
or hold responsible.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
Good luck and Godspeed.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -31,12 +31,16 @@ BUILD_LDFLAGS := -s -w -X github.com/ditatompel/xmr-remote-nodes/internal/config
# This called from air cmd (see .air.toml)
.PHONY: dev
dev: templ tailwind
dev:
go build -ldflags="$(BUILD_LDFLAGS)" -tags server -o ./tmp/main .
.PHONY: build
build: client server
.PHONY: ui
ui:
go generate ./...
.PHONY: client
client:
CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build \
@ -47,7 +51,7 @@ client:
-o bin/${BINARY_NAME}-client-linux-arm64
.PHONY: server
server: prepare templ tailwind
server: ui
CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build \
-ldflags="$(BUILD_LDFLAGS)" -tags server \
-o bin/${BINARY_NAME}-server-linux-amd64
@ -55,37 +59,11 @@ server: prepare templ tailwind
-ldflags="$(BUILD_LDFLAGS)" -tags server \
-o bin/${BINARY_NAME}-server-linux-arm64
.PHONY: prepare
prepare:
bun install --frozen-lockfile
@mkdir -p ./internal/handler/views/assets/js
cp ./node_modules/htmx.org/dist/htmx.min.js ./internal/handler/views/assets/js
cp ./node_modules/clipboard/dist/clipboard.min.js ./internal/handler/views/assets/js
# Compile template
.PHONY: templ
templ:
@echo "Compiling Templ template..."
templ generate
.PHONY: tailwind
tailwind:
mkdir -p ./internal/handler/views/assets/css
@echo "Compiling TailwindCSS..."
bun tailwindcss -i ./internal/handler/views/src/css/main.css \
-o ./internal/handler/views/assets/css/main.min.css \
-c ./tailwind.config.js \
--minify
bun build ./internal/handler/views/src/js/main.js --minify \
--outfile ./internal/handler/views/assets/js/main.min.js
.PHONY: clean
clean:
go clean
rm -rfv ./bin
rm -rfv ./tmp/main
rm -rf ./internal/handler/views/*_templ.go
rm -rf ./internal/handler/views/assets/css/
rm -rf ./frontend/build
.PHONY: lint
lint:
@ -104,10 +82,8 @@ bench:
# And make sure the inventory and deploy-*.yml file is properly configured.
.PHONY: deploy-server
deploy-server:
ansible-playbook -i ./deployment/ansible/inventory.ini \
-l server ./deployment/ansible/deploy-server.yml -K
ansible-playbook -i ./deployment/ansible/inventory.ini -l server ./deployment/ansible/deploy-server.yml -K
.PHONY: deploy-prober
deploy-prober:
ansible-playbook -i ./deployment/ansible/inventory.ini \
-l prober ./deployment/ansible/deploy-prober.yml -K
ansible-playbook -i ./deployment/ansible/inventory.ini -l prober ./deployment/ansible/deploy-prober.yml -K

View file

@ -28,13 +28,7 @@ serves the `/api` endpoint that is used by the clients and the Web UI itself.
To build the executable binaries, you need:
- Go >= 1.22
- Bun >= 1.1.26
- [a-h/templ][templ-repo] v0.2.778
> **Note**:
>
> - If you want to contribute to the code, please use exact templ version
> (v0.2.778).
- NodeJS >= 20
### Server & Prober requirements
@ -73,18 +67,13 @@ Systemd example: [xmr-nodes-prober.service][prober-systemd-service] and
## Development and Deployment
1. Clone or fork this repository.
2. Prepare the assets: `make prepare`,
3. Run `air serve` (live reload using [air-verse/air][air-repo]).
See the [Makefile](./Makefile).
## ToDo's
- :white_check_mark: Accept IPv6 nodes.
- :white_check_mark: Use `a-h/templ` and `HTMX` instead of `Svelte`.
- Use `a-h/templ` and `HTMX` instead of `Svelte`.
- Use Go standard `net/http` instead of `fiber`.
- :white_check_mark: Accept I2P nodes.
## Acknowledgement
@ -112,20 +101,18 @@ XMR Donation address:
8BWYe6GzbNKbxe3D8mPkfFMQA2rViaZJFhWShhZTjJCNG6EZHkXRZCKHiuKmwwe4DXDYF8KKcbGkvNYaiRG3sNt7JhnVp7D
```
![](./internal/handler/views/assets/img/monerotip.png)
![](./frontend/static/img/monerotip.png)
Thank you!
## License
This project is licensed under [BSD-3-Clause](./LICENSE) license.
This project is licensed under [GLWTPL](./LICENSE).
[templ-repo]: https://github.com/a-h/templ "a-h/templ GitHub repository"
[geoip-doc]: https://dev.maxmind.com/geoip/geoip2/geolite2/ "GeoIP documentation"
[server-systemd-service]: ./deployment/init/xmr-nodes-server.service "systemd service example for server"
[prober-systemd-service]: ./deployment/init/xmr-nodes-prober.service "systemd service example for prober"
[prober-systemd-timer]: ./deployment/init/xmr-nodes-prober.timer "systemd timer example for prober"
[air-repo]: https://github.com/air-verse/air "Air - Live reload for Go apps"
[jtgrassie-monero-pool]: https://github.com/jtgrassie/monero-pool "A Monero mining pool server written in C"
[rclone]: https://github.com/rclone/rclone "rclone GitHub repository"
[monerofail-repo]: https://github.com/lalanza808/monero.fail "Lalanza808's monero.fail GitHub repository"

View file

@ -1 +1 @@
v0.2.1
v0.1.3

BIN
bun.lockb

Binary file not shown.

View file

@ -21,12 +21,11 @@ import (
"golang.org/x/net/proxy"
)
const RPCUserAgent = "ditatombot/0.0.2 (Monero RPC Monitoring; https://github.com/ditatompel/xmr-remote-nodes)"
const RPCUserAgent = "ditatombot/0.0.1 (Monero RPC Monitoring; https://github.com/ditatompel/xmr-remote-nodes)"
const (
errNoEndpoint = errProber("no SERVER_ENDPOINT was provided")
errNoTorSocks = errProber("no TOR_SOCKS was provided")
errNoI2PSocks = errProber("no I2P_SOCKS was provided")
errNoAPIKey = errProber("no API_KEY was provided")
errInvalidCredentials = errProber("invalid API_KEY credentials")
)
@ -42,8 +41,6 @@ type proberClient struct {
apiKey string // prober api key
acceptTor bool // accept tor
torSOCKS string // IP:Port of tor socks
acceptI2P bool // accept i2p
I2PSOCKS string // IP:Port of i2p socks
acceptIPv6 bool // accept ipv6
message string // message to include when reporting back to server
}
@ -55,8 +52,6 @@ func newProber() *proberClient {
apiKey: cfg.APIKey,
acceptTor: cfg.AcceptTor,
torSOCKS: cfg.TorSOCKS,
acceptI2P: cfg.AcceptI2P,
I2PSOCKS: cfg.I2PSOCKS,
acceptIPv6: cfg.IPv6Capable,
}
}
@ -72,9 +67,6 @@ var ProbeCmd = &cobra.Command{
if t, _ := cmd.Flags().GetBool("no-tor"); t {
prober.SetAcceptTor(false)
}
if t, _ := cmd.Flags().GetBool("no-i2p"); t {
prober.SetAcceptI2P(false)
}
if err := prober.Run(); err != nil {
switch err.(type) {
@ -96,10 +88,6 @@ func (p *proberClient) SetAcceptTor(acceptTor bool) {
p.acceptTor = acceptTor
}
func (p *proberClient) SetAcceptI2P(acceptI2P bool) {
p.acceptI2P = acceptI2P
}
func (p *proberClient) SetAcceptIPv6(acceptIPv6 bool) {
p.acceptIPv6 = acceptIPv6
}
@ -134,9 +122,6 @@ func (p *proberClient) validateConfig() error {
if p.acceptTor && p.torSOCKS == "" {
return errNoTorSocks
}
if p.acceptI2P && p.I2PSOCKS == "" {
return errNoI2PSocks
}
return nil
}
@ -148,11 +133,6 @@ func (p *proberClient) fetchJob() (monero.Node, error) {
acceptTor = 1
}
acceptI2P := 0
if p.acceptI2P {
acceptI2P = 1
}
acceptIPv6 := 0
if p.acceptIPv6 {
acceptIPv6 = 1
@ -160,7 +140,7 @@ func (p *proberClient) fetchJob() (monero.Node, error) {
var node monero.Node
uri := fmt.Sprintf("%s/api/v1/job?accept_tor=%d&accept_i2p=%d&accept_ipv6=%d", p.endpoint, acceptTor, acceptI2P, acceptIPv6)
uri := fmt.Sprintf("%s/api/v1/job?accept_tor=%d&accept_ipv6=%d", p.endpoint, acceptTor, acceptIPv6)
slog.Info(fmt.Sprintf("[PROBE] Getting node from %s", uri))
req, err := http.NewRequest(http.MethodGet, uri, nil)
@ -218,16 +198,8 @@ func (p *proberClient) fetchNode(node monero.Node) (monero.Node, error) {
req.Header.Set("Origin", "https://xmr.ditatompel.com")
var client http.Client
var socks5 string
if p.acceptTor && node.IsTor {
socks5 = p.torSOCKS
} else if p.acceptI2P && node.IsI2P {
socks5 = p.I2PSOCKS
}
if socks5 != "" {
dialer, err := proxy.SOCKS5("tcp", socks5, nil, proxy.Direct)
dialer, err := proxy.SOCKS5("tcp", p.torSOCKS, nil, proxy.Direct)
if err != nil {
return node, err
}
@ -296,7 +268,7 @@ func (p *proberClient) fetchNode(node monero.Node) (monero.Node, error) {
node.CORSCapable = true
}
if !node.IsTor && !node.IsI2P {
if !node.IsTor {
hostIp, err := net.LookupIP(node.Hostname)
if err != nil {
fmt.Println("Warning: Could not resolve hostname: " + node.Hostname)
@ -363,7 +335,7 @@ func (p *proberClient) fetchFee(client http.Client, endpoint string) (uint, erro
}
func (p *proberClient) reportResult(node monero.Node, tookTime float64) error {
if !node.IsTor && !node.IsI2P {
if !node.IsTor {
if hostIps, err := net.LookupIP(node.Hostname); err == nil {
node.IPv6Only = ip.IsIPv6Only(hostIps)
node.IPAddresses = ip.SliceToString(hostIps)

View file

@ -29,8 +29,7 @@ func init() {
Root.PersistentFlags().StringVarP(&configFile, "config-file", "c", "", "Default to .env")
Root.AddCommand(client.ProbeCmd)
client.ProbeCmd.Flags().StringP("endpoint", "e", "", "Server endpoint")
client.ProbeCmd.Flags().Bool("no-tor", false, "Do not probe tor nodes")
client.ProbeCmd.Flags().Bool("no-i2p", false, "Do not probe i2p nodes")
client.ProbeCmd.Flags().Bool("no-tor", false, "Only probe clearnet nodes")
}
func initConfig() {

View file

@ -8,14 +8,15 @@ import (
"syscall"
"time"
"github.com/ditatompel/xmr-remote-nodes/frontend"
"github.com/ditatompel/xmr-remote-nodes/internal/config"
"github.com/ditatompel/xmr-remote-nodes/internal/cron"
"github.com/ditatompel/xmr-remote-nodes/internal/database"
"github.com/ditatompel/xmr-remote-nodes/internal/handler"
"github.com/ditatompel/xmr-remote-nodes/internal/handler/views"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/spf13/cobra"
@ -55,7 +56,11 @@ func serve() {
}
// Define Fiber config & app.
app := handler.NewServer()
app := fiber.New(fiber.Config{
Prefork: appCfg.Prefork,
ProxyHeader: appCfg.ProxyHeader,
AppName: "XMR Nodes Aggregator",
})
// recover
app.Use(recover.New(recover.Config{EnableStackTrace: true}))
@ -74,8 +79,11 @@ func serve() {
AllowCredentials: true,
}))
app.Use("/assets", views.EmbedAssets())
app.Routes()
handler.V1Api(app)
app.Use("/", filesystem.New(filesystem.Config{
Root: frontend.SvelteKitHandler(),
// NotFoundFile: "index.html",
}))
// go routine to capture system calls
go func() {

10
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

4
frontend/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

8
frontend/.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

3
frontend/README.md Normal file
View file

@ -0,0 +1,3 @@
# UI
The UI is generated and embedded when the Go project is built. See [./frontend/embed.go](https://github.com/ditatompel/xmr-remote-nodes/blob/main/frontend/embed.go#L10-L13).

21
frontend/embed.go Normal file
View file

@ -0,0 +1,21 @@
package frontend
import (
"embed"
"io/fs"
"log"
"net/http"
)
//go:generate npm ci
//go:generate npm run build
//go:embed build/*
var f embed.FS
func SvelteKitHandler() http.FileSystem {
build, err := fs.Sub(f, "build")
if err != nil {
log.Fatal(err)
}
return http.FS(build)
}

23
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
js.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
];

18
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

3908
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
frontend/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "xmr-nodes-frontend",
"version": "v0.1.3",
"private": true,
"scripts": {
"dev": "VITE_API_URL=http://127.0.0.1:18901 vite dev --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@floating-ui/dom": "^1.6.11",
"@skeletonlabs/skeleton": "^2.10.3",
"@skeletonlabs/tw-plugin": "^0.4.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.7.3",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/forms": "^0.5.9",
"@types/eslint": "^9.6.1",
"@vincjo/datatables": "^1.14.10",
"autoprefixer": "^10.4.20",
"date-fns": "^4.1.0",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.0",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7",
"svelte": "^5.1.3",
"svelte-check": "^4.0.5",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.8"
},
"type": "module"
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

25
frontend/src/app.css Normal file
View file

@ -0,0 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
html,
body {
@apply h-full;
}
p {
@apply mb-2;
}
.link {
@apply text-primary-800 dark:text-primary-500 hover:brightness-110;
}
a.external {
@apply link after:content-['_↗'];
}
.section-container {
@apply mx-auto w-full max-w-7xl p-4;
}
.hero-gradient {
background-image: radial-gradient(at 0% 0%, rgba(242, 104, 34, 0.4) 0px, transparent 50%),
radial-gradient(at 98% 1%, rgba(var(--color-warning-900) / 0.33) 0px, transparent 50%);
}

34
frontend/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,34 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
interface ImportMetaEnv {
VITE_API_URL: string;
}
interface MoneroNode {
id: number;
hostname: string;
ip: string;
port: number;
protocol: string;
is_tor: boolean;
is_available: boolean;
nettype: string;
ip_addresses: string;
}
interface ApiResponse {
status: string;
message: string;
data: null | object | object[];
}
}
export {};

13
frontend/src/app.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="false" data-theme="skeleton">
<div style="display: contents" class="h-full">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,14 @@
<script>
import { version } from '$app/environment';
</script>
<div class="flex w-full items-end border-t border-surface-500/10 bg-surface-50 dark:bg-surface-900">
<footer class="w-full">
<div class="bg-surface-500/5">
<div class="container mx-auto px-5 py-4">
<!-- prettier-ignore -->
<p class="text-center text-sm">XMR Nodes {version}, <a href="https://github.com/ditatompel/xmr-remote-nodes" target="_blank" rel="noopener" class="external">source code</a> licensed under <strong>GLWTPL</strong>.</p>
</div>
</div>
</footer>
</div>

View file

@ -0,0 +1,11 @@
<!-- prettier-ignore -->
<section id="site-news" class="mx-auto w-full max-w-4xl px-4 pb-7">
<div class="alert variant-ghost-secondary shadow-xl">
<div class="alert-message">
<h2 class="h3 text-center">Quick Info</h2>
<p>
On November 7th, 2024, localmonero.co website will be taken down. If you have an account and a balance, withdraw your funds prior to this date. For more information, visit <a href="https://localmonero.co/blog/announcements/winding-down" class="external" target="_blank" rel="noopener">localmonero.co announcement page</a>.
</p>
</div>
</div>
</section>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
let intervalId: number | undefined;
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) {
handler.invalidate();
intervalId = setInterval(() => {
handler.invalidate();
}, seconds * 1000);
}
};
$: startInterval();
onDestroy(() => {
clearInterval(intervalId);
});
</script>
<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>

View file

@ -0,0 +1,66 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
const pageNumber = handler.getPageNumber();
const pageCount = handler.getPageCount();
const pages = handler.getPages({ ellipsis: true });
const setPage = (value: 'previous' | 'next' | number) => {
handler.setPage(value);
handler.invalidate();
};
</script>
<section class={$$props.class ?? ''}>
{#if $pages === undefined}
<button type="button" class="sm-btn" on:click={() => setPage('previous')}> &#10094; </button>
<button class="mx-4">page <b>{$pageNumber}</b></button>
<button type="button" class="sm-btn" on:click={() => setPage('next')}>&#10095;</button>
{:else}
<div class="lg:hidden">
<button type="button" class="sm-btn" on:click={() => setPage('previous')}> &#10094; </button>
<button class="mx-4">page <b>{$pageNumber}</b></button>
<button
class="sm-btn"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage('next')}
>
&#10095;
</button>
</div>
<div class="btn-group variant-ghost-surface hidden lg:block">
<button
type="button"
class="hover:variant-soft-secondary"
class:disabled={$pageNumber === 1}
on:click={() => setPage('previous')}>&#10094;</button
>
{#each $pages as page}<button
type="button"
class="hover:variant-filled-secondary"
class:!variant-filled-primary={$pageNumber === page}
class:ellipse={page === null}
on:click={() => setPage(page)}>{page ?? '...'}</button
>{/each}
<button
type="button"
class="hover:variant-soft-secondary"
class:disabled={$pageNumber === $pageCount}
on:click={() => setPage('next')}
>
&#10095;
</button>
</div>
{/if}
</section>
<style lang="postcss">
.disabled {
@apply cursor-not-allowed;
}
</style>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
const rowCount = handler.getRowCount();
</script>
{#if $rowCount === undefined}
<div></div>
{:else}
<div class={$$props.class ?? 'mr-6 leading-8 lg:leading-10'}>
{#if $rowCount.total > 0}
<b>{$rowCount.start}</b>
- <b>{$rowCount.end}</b>
/ <b>{$rowCount.total}</b>
{:else}
No entries found
{/if}
</div>
{/if}

View file

@ -0,0 +1,33 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
export let options = [5, 10, 20, 50, 100];
export let labelId = 'rowsPerPage';
const rowsPerPage = handler.getRowsPerPage();
const setRowsPerPage = () => {
handler.setPage(1);
handler.invalidate();
};
</script>
<div class="flex place-items-center">
<label for={labelId}>Show</label>
<select
class="select ml-2"
id={labelId}
name="rowsPerPage"
bind:value={$rowsPerPage}
on:change={setRowsPerPage}
>
{#each options as option}
<option value={option}>
{option}
</option>
{/each}
</select>
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { DataHandler } from '@vincjo/datatables/remote';
export let handler: DataHandler;
let value: string;
let timeout: any;
const search = () => {
handler.search(value);
clearTimeout(timeout);
timeout = setTimeout(() => {
handler.invalidate();
}, 400);
};
</script>
<input
class="input-variant-secondary input w-36 sm:w-64"
type="search"
name="tableGlobalSearch"
placeholder="Search..."
bind:value
on:input={search}
/>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
export let filterBy: keyof T;
/** @type {string} */
export let placeholder: string = 'Filter';
/** @type {number} */
export let colspan: number = 1;
let value: string = '';
let timeout: any;
const filter = () => {
handler.filter(value, filterBy);
clearTimeout(timeout);
timeout = setTimeout(() => {
handler.invalidate();
}, 400);
};
</script>
<th {colspan}>
<input
class="input variant-form-material h-8 w-full text-sm"
type="text"
{placeholder}
bind:value
on:input={filter}
/>
</th>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import type { DataHandler, Row } from '@vincjo/datatables/remote';
type T = $$Generic<Row>;
export let handler: DataHandler<T>;
export let orderBy: keyof T;
const update = () => {
handler.sort(orderBy);
handler.invalidate();
};
</script>
<th on:click={update} class="cursor-pointer select-none p-2 px-5">
<div class="flex h-full items-center justify-start gap-x-2">
<slot /> ↕️
</div>
</th>

View file

@ -0,0 +1,7 @@
export { default as DtSrPagination } from './DtSrPagination.svelte';
export { default as DtSrRowCount } from './DtSrRowCount.svelte';
export { default as DtSrRowsPerPage } from './DtSrRowsPerPage.svelte';
export { default as DtSrSearch } from './DtSrSearch.svelte';
export { default as DtSrThFilter } from './DtSrThFilter.svelte';
export { default as DtSrThSort } from './DtSrThSort.svelte';
export { default as DtSrAutoRefresh } from './DtSrAutoRefresh.svelte';

View file

@ -0,0 +1,33 @@
<script lang="ts">
export let cc: string;
export let country_name: string;
export let city: string;
export let asn: number;
export let asn_name: string;
$: lowerCc = cc.toLowerCase();
</script>
{#if cc != ''}
{#if city !== ''}
{city},
{/if}
{country_name}
<img class="inline-block" src="/img/cf/{lowerCc}.svg" alt="{cc} Flag" width="22px" />
{/if}
{#if asn !== 0}
<br /><a
class="external !text-purple-800 dark:!text-purple-400"
href="https://www.ditatompel.com/asn/{asn}"
target="_blank"
rel="noopener">AS{asn}</a
>
(<span class="font-semibold text-green-800 dark:text-green-500">{asn_name}</span>)
{/if}
<style lang="postcss">
a {
@apply font-semibold text-sky-800 underline dark:text-sky-500;
}
</style>

View file

@ -0,0 +1,10 @@
<script lang="ts">
export let estimate_fee: number;
export let majority_fee: number;
</script>
{#if estimate_fee !== majority_fee}
<span class="text-orange-800 dark:text-orange-300">{estimate_fee}<br />(CAUTION!)</span>
{:else}
{estimate_fee}
{/if}

View file

@ -0,0 +1,56 @@
<script>
import { getModalStore } from '@skeletonlabs/skeleton';
import { formatHostname } from '$lib/utils/strings';
const modalStore = getModalStore();
/**
* @type {{
* is_tor: boolean,
* hostname: string,
* port: number,
* ipv6_only: boolean
* }}
*/
export let is_tor;
export let hostname;
export let port;
export let ipv6_only;
/** @type {string} */
export let ip_addresses;
/**
* @param {string} onionAddr
* @param {number} port
*/
function modalAlert(onionAddr, port) {
/** @typedef {import('@skeletonlabs/skeleton').ModalSettings} ModalSettings */
/** @type {ModalSettings} */
const modal = {
type: 'alert',
title: 'Hostname:',
body: '<code class="code">' + onionAddr + ':' + port + '</code>'
};
modalStore.trigger(modal);
}
</script>
{#if is_tor}
<button
class="max-w-40 truncate text-orange-800 dark:text-orange-300"
on:click={() => modalAlert(hostname, port)}
>
👁 {hostname}
</button><br />.onion:<span class="text-indigo-800 dark:text-indigo-400">{port}</span>
<span class="text-gray-700 dark:text-gray-400">(TOR)</span>
{:else}
{formatHostname(hostname)}:<span class="text-indigo-800 dark:text-indigo-400">{port}</span><br />
<div class="max-w-40 text-ellipsis overflow-x-auto md:overflow-hidden hover:overflow-visible">
<span class="whitespace-break-spaces text-gray-700 dark:text-gray-400"
>{ip_addresses.replace(/,/g, ' ')}</span
>
{#if ipv6_only}
<span class="text-rose-800 dark:text-rose-400">(IPv6 only)</span>
{/if}
</div>
{/if}

View file

@ -0,0 +1,15 @@
<script>
/** @type {string} */
export let nettype;
/** @type {number} */
export let height;
</script>
{#if nettype === 'stagenet'}
<span class="font-semibold uppercase text-sky-800 dark:text-sky-500">{nettype}</span>
{:else if nettype === 'testnet'}
<span class="font-semibold uppercase text-rose-800 dark:text-rose-400">{nettype}</span>
{:else}
<span class="font-semibold uppercase text-green-800 dark:text-green-500">{nettype}</span>
{/if}
<br />{height}

View file

@ -0,0 +1,16 @@
<script>
/** @type {string} */
export let protocol;
/** @type {boolean} */
export let cors;
</script>
{#if protocol === 'http'}
<span class="font-semibold uppercase text-sky-800 dark:text-sky-500">{protocol}</span>
{:else}
<span class="font-semibold uppercase text-green-800 dark:text-green-500">{protocol}</span>
{/if}
{#if cors}
<br />(CORS 💪)
{/if}

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { getDistinct } from '$lib/utils/arrays';
export let filterValue; //: Writable<string>;
export let preFilteredValues; //: Readable<unknown[]>;
$: uniqueValues = getDistinct($preFilteredValues);
</script>
<div class="pt-2">
<select name="filterAnonymity" class="select" bind:value={$filterValue} on:click|stopPropagation>
<option value={undefined}>All</option>
{#each uniqueValues as value}
{#if value === true}
<option {value}>TOR</option>
{:else}
<option {value}>CLEARNET</option>
{/if}
{/each}
</select>
</div>

View file

@ -0,0 +1,22 @@
<script>
import { getDistinct } from '$lib/utils/arrays';
/** @type {string} */
export let filterName;
export let filterValue; //: Writable<string>;
export let preFilteredValues; //: Readable<unknown[]>;
$: uniqueValues = getDistinct($preFilteredValues);
</script>
<div class="pt-2">
<select name={filterName} class="select" bind:value={$filterValue} on:click|stopPropagation>
<option value={undefined}>All</option>
{#each uniqueValues as value}
{#if value === ''}
<option {value}>UNKNOWN</option>
{:else}
<option {value}>{value.toUpperCase()}</option>
{/if}
{/each}
</select>
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { getDistinct } from '$lib/utils/arrays';
export let filterValue; //: Writable<string>;
export let preFilteredValues; //: Readable<unknown[]>;
$: uniqueValues = getDistinct($preFilteredValues);
</script>
<div class="pt-2">
<select name="filterStatus" class="select" bind:value={$filterValue} on:click|stopPropagation>
<option value={undefined}>All</option>
{#each uniqueValues as value}
{#if value === true}
<option {value}>ONLINE</option>
{:else}
<option {value}>OFFLINE</option>
{/if}
{/each}
</select>
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
export let is_available: boolean;
export let statuses: number[];
</script>
{#if is_available}
<span class="font-semibold text-green-800 dark:text-green-500">Online</span>
{:else}
<span class="text-rose-800 dark:text-rose-400">Offline</span>
{/if}
<br />
{#each statuses as status}
{#if status === 1}
<span class="text-success-700 dark:text-success-400 mr-1"></span>
{:else if status === 0}
<span class="text-error-700 dark:text-error-400 mr-1"></span>
{:else}
<span class="text-surface-400 dark:text-surface-600 mr-1"></span>
{/if}
{/each}

View file

@ -0,0 +1,13 @@
<script lang="ts">
export let uptime: number;
</script>
{#if uptime >= 98}
<span class="text-green-800 dark:text-green-500">{uptime}%</span>
{:else if uptime < 98 && uptime >= 80}
<span class="text-sky-800 dark:text-sky-500">{uptime}%</span>
{:else if uptime < 80 && uptime > 75}
<span class="text-orange-800 dark:text-orange-300">{uptime}%</span>
{:else}
<span class="text-rose-800 dark:text-rose-400">{uptime}%</span>
{/if}

View file

@ -0,0 +1,10 @@
export { default as CountryCellWithAsn } from './CountryCellWithAsn.svelte';
export { default as EstimateFeeCell } from './EstimateFeeCell.svelte';
export { default as HostPortCell } from './HostPortCell.svelte';
export { default as NetTypeCell } from './NetTypeCell.svelte';
export { default as ProtocolCell } from './ProtocolCell.svelte';
export { default as SelectAnonymityFilter } from './SelectAnonymityFilter.svelte';
export { default as SelectFilter } from './SelectFilter.svelte';
export { default as SelectStatusFilter } from './SelectStatusFilter.svelte';
export { default as StatusCell } from './StatusCell.svelte';
export { default as UptimeCell } from './UptimeCell.svelte';

View file

@ -0,0 +1,21 @@
<script>
import { page } from '$app/stores';
import { adminNavs } from './navs';
import { getDrawerStore } from '@skeletonlabs/skeleton';
const drawerStore = getDrawerStore();
$: style = (/** @type {string} */ href) =>
$page.url.pathname.startsWith(href) ? 'bg-primary-500' : '';
</script>
<nav class="list-nav p-4">
<ul>
{#each adminNavs as nav}
<li>
<a href={nav.path} class={style(nav.path)} on:click={() => drawerStore.close()}
>{nav.name}</a
>
</li>
{/each}
</ul>
</nav>

View file

@ -0,0 +1,72 @@
<script>
import { invalidateAll, goto } from '$app/navigation';
import { LightSwitch, getDrawerStore } from '@skeletonlabs/skeleton';
import { apiUri } from '$lib/utils/common';
const drawerStore = getDrawerStore();
/** @type {ApiResponse} */
let formResult;
/** @param {{ currentTarget: EventTarget & HTMLFormElement}} event */
async function handleLogout(event) {
const data = new FormData(event.currentTarget);
const response = await fetch(event.currentTarget.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(Object.fromEntries(data))
});
formResult = await response.json();
if (formResult.status === 'ok') {
await invalidateAll();
goto('/login/');
}
}
</script>
<nav class="bg-surface-100-800-token fixed top-0 z-30 w-full shadow-2xl">
<div class="px-3 py-2 lg:px-5 lg:pl-3">
<div class="flex items-center justify-between">
<div class="flex items-center justify-start rtl:justify-end">
<button
class="btn btn-sm inline-flex items-center md:hidden"
aria-label="Mobile Drawer Button"
on:click={() => drawerStore.open({})}
>
<span>
<svg viewBox="0 0 100 80" class="fill-token h-4 w-4">
<rect width="100" height="20" />
<rect y="30" width="100" height="20" />
<rect y="60" width="100" height="20" />
</svg>
</span>
</button>
<a href="/app/prober/" class="ms-2 flex md:me-24" aria-label="title">
<span class="hidden self-center whitespace-nowrap text-2xl font-semibold lg:block"
>XMR Nodes</span
>
</a>
</div>
<div class="flex items-center">
<div class="ms-3 flex items-center space-x-4">
<LightSwitch />
<form
action={apiUri('/auth/logout')}
method="POST"
on:submit|preventDefault={handleLogout}
>
<input type="hidden" name="logout" value="logout" />
<button type="submit" class="btn btn-sm variant-filled-error" role="menuitem">
Sign out
</button>
</form>
</div>
</div>
</div>
</div>
</nav>

View file

@ -0,0 +1,34 @@
<script>
import { page } from '$app/stores';
import { adminNavs } from './navs';
</script>
<aside
id="logo-sidebar"
class="bg-surface-100-800-token fixed left-0 top-0 z-20 h-screen w-64 -translate-x-full pt-20 shadow-2xl transition-transform sm:translate-x-0"
aria-label="Sidebar"
>
<div class="h-full overflow-y-auto px-3 pb-4">
<ul class="space-y-2 font-medium list-none" data-sveltekit-preload-data="false">
{#each adminNavs as nav}
<li>
<a
href={nav.path}
class={$page.url.pathname.startsWith(nav.path) ? 'active' : 'nav-link'}
>
<span class="ms-3">{nav.name}</span>
</a>
</li>
{/each}
</ul>
</div>
</aside>
<style lang="postcss">
.active {
@apply flex items-center rounded-lg bg-primary-500 p-2;
}
.nav-link {
@apply flex items-center rounded-lg p-2 hover:bg-secondary-500 hover:text-white;
}
</style>

View file

@ -0,0 +1,63 @@
<script>
import { page } from '$app/stores';
import { navs } from './navs';
import { LightSwitch, getDrawerStore } from '@skeletonlabs/skeleton';
const drawerStore = getDrawerStore();
</script>
<nav class="fixed w-full z-20 top-0 start-0 bg-surface-100-800-token shadow-2xl">
<div class="mx-auto flex max-w-screen-xl flex-wrap items-center justify-between px-4 py-1">
<a href="/" class="flex items-center space-x-3 rtl:space-x-reverse" aria-label="xmr nodes">
<span class="self-center whitespace-nowrap text-2xl font-semibold lg:block">XMR Nodes</span>
</a>
<div class="flex items-center space-x-1 md:order-2 md:space-x-0 rtl:space-x-reverse">
<LightSwitch />
<button
class="btn btn-sm mr-4 md:hidden"
aria-label="Mobile Drawer Button"
on:click={() => drawerStore.open({})}
>
<span>
<svg viewBox="0 0 100 80" class="fill-token h-4 w-4">
<rect width="100" height="20" />
<rect y="30" width="100" height="20" />
<rect y="60" width="100" height="20" />
</svg>
</span>
</button>
</div>
<div class="hidden w-full items-center justify-between md:order-1 md:flex md:w-auto">
<ul
class="flex flex-row space-x-1 rounded-lg bg-white p-0 dark:bg-gray-900 rtl:space-x-reverse"
>
<li>
<a
href="/"
class={$page.url.pathname === '/' ? 'active' : 'nav-link'}
aria-current={$page.url.pathname === '/' ? 'page' : undefined}>Home</a
>
</li>
{#each navs as nav}
<li>
<a
href={nav.path}
class={$page.url.pathname.startsWith(nav.path) ? 'active' : 'nav-link'}
>
{nav.name}
</a>
</li>
{/each}
</ul>
</div>
</div>
</nav>
<style lang="postcss">
.active {
@apply block rounded bg-primary-500 p-2 text-black;
}
.nav-link {
@apply block rounded hover:bg-secondary-500 md:p-2 hover:text-white;
}
</style>

View file

@ -0,0 +1,28 @@
<script>
import { page } from '$app/stores';
import { navs } from './navs';
import { getDrawerStore } from '@skeletonlabs/skeleton';
const drawerStore = getDrawerStore();
$: classes = (/** @type {string} */ href) =>
$page.url.pathname.startsWith(href) ? 'bg-primary-500' : '';
</script>
<nav class="list-nav p-4">
<ul>
<li>
<a
href="/"
class={$page.url.pathname === '/' ? 'bg-primary-500' : ''}
on:click={() => drawerStore.close()}>Home</a
>
</li>
{#each navs as nav}
<li>
<a href={nav.path} class={classes(nav.path)} on:click={() => drawerStore.close()}
>{nav.name}</a
>
</li>
{/each}
</ul>
</nav>

View file

@ -0,0 +1,5 @@
export { default as MainNav } from './MainNav.svelte';
export { default as MobileDrawer } from './MobileDrawer.svelte';
export { default as AdminNav } from './AdminNav.svelte';
export { default as AdminSidebar } from './AdminSidebar.svelte';
export { default as AdminMobileDrawer } from './AdminMobileDrawer.svelte';

View file

@ -0,0 +1,9 @@
export const adminNavs = [
{ name: 'Prober', path: '/app/prober/' },
{ name: 'Crons', path: '/app/crons/' }
];
export const navs = [
{ name: 'Remote Nodes', path: '/remote-nodes/' },
{ name: 'Add Node', path: '/add-node/' }
];

View file

@ -0,0 +1,10 @@
<!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<svg
xmlns="http://www.w3.org/2000/svg"
fill={`${$$props.fill ?? 'currentColor'}`}
class={`${$$props.class}`}
viewBox="0 0 512 512"
><path
d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287V510.1C413.8 494.8 512 386.9 512 256h0z"
/></svg
>

After

Width:  |  Height:  |  Size: 582 B

View file

@ -0,0 +1,10 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill={`${$$props.fill ?? 'currentColor'}`}
class={`${$$props.class}`}
viewBox="0 0 24 24"
>
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 880 B

View file

@ -0,0 +1,10 @@
<!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<svg
xmlns="http://www.w3.org/2000/svg"
fill={`${$$props.fill ?? 'currentColor'}`}
class={`${$$props.class}`}
viewBox="0 0 496 512"
><path
d="M352 384h108.4C417 455.9 338.1 504 248 504S79 455.9 35.6 384H144V256.2L248 361l104-105v128zM88 336V128l159.4 159.4L408 128v208h74.8c8.5-25.1 13.2-52 13.2-80C496 119 385 8 248 8S0 119 0 256c0 28 4.6 54.9 13.2 80H88z"
/></svg
>

After

Width:  |  Height:  |  Size: 528 B

View file

@ -0,0 +1,10 @@
<!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<svg
xmlns="http://www.w3.org/2000/svg"
fill={`${$$props.fill ?? 'currentColor'}`}
class={`${$$props.class}`}
viewBox="0 0 512 512"
><path
d="M373 138.6c-25.2 0-46.3-17.5-51.9-41l0 0c-30.6 4.3-54.2 30.7-54.2 62.4l0 .2c47.4 1.8 90.6 15.1 124.9 36.3c12.6-9.7 28.4-15.5 45.5-15.5c41.3 0 74.7 33.4 74.7 74.7c0 29.8-17.4 55.5-42.7 67.5c-2.4 86.8-97 156.6-213.2 156.6S45.5 410.1 43 323.4C17.6 311.5 0 285.7 0 255.7c0-41.3 33.4-74.7 74.7-74.7c17.2 0 33 5.8 45.7 15.6c34-21.1 76.8-34.4 123.7-36.4l0-.3c0-44.3 33.7-80.9 76.8-85.5C325.8 50.2 347.2 32 373 32c29.4 0 53.3 23.9 53.3 53.3s-23.9 53.3-53.3 53.3zM157.5 255.3c-20.9 0-38.9 20.8-40.2 47.9s17.1 38.1 38 38.1s36.6-9.8 37.8-36.9s-14.7-49.1-35.7-49.1zM395 303.1c-1.2-27.1-19.2-47.9-40.2-47.9s-36.9 22-35.7 49.1c1.2 27.1 16.9 36.9 37.8 36.9s39.3-11 38-38.1zm-60.1 70.8c1.5-3.6-1-7.7-4.9-8.1c-23-2.3-47.9-3.6-73.8-3.6s-50.8 1.3-73.8 3.6c-3.9 .4-6.4 4.5-4.9 8.1c12.9 30.8 43.3 52.4 78.7 52.4s65.8-21.6 78.7-52.4z"
/></svg
>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,10 @@
<!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<svg
xmlns="http://www.w3.org/2000/svg"
fill={`${$$props.fill ?? 'currentColor'}`}
class={`${$$props.class}`}
viewBox="0 0 496 512"
><path
d="M248 8C111 8 0 119 0 256S111 504 248 504 496 393 496 256 385 8 248 8zM363 176.7c-3.7 39.2-19.9 134.4-28.1 178.3-3.5 18.6-10.3 24.8-16.9 25.4-14.4 1.3-25.3-9.5-39.3-18.7-21.8-14.3-34.2-23.2-55.3-37.2-24.5-16.1-8.6-25 5.3-39.5 3.7-3.8 67.1-61.5 68.3-66.7 .2-.7 .3-3.1-1.2-4.4s-3.6-.8-5.1-.5q-3.3 .7-104.6 69.1-14.8 10.2-26.9 9.9c-8.9-.2-25.9-5-38.6-9.1-15.5-5-27.9-7.7-26.8-16.3q.8-6.7 18.5-13.7 108.4-47.2 144.6-62.3c68.9-28.6 83.2-33.6 92.5-33.8 2.1 0 6.6 .5 9.6 2.9a10.5 10.5 0 0 1 3.5 6.7A43.8 43.8 0 0 1 363 176.7z"
/></svg
>

After

Width:  |  Height:  |  Size: 831 B

View file

@ -0,0 +1,10 @@
<!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
<svg
xmlns="http://www.w3.org/2000/svg"
fill={`${$$props.fill ?? 'currentColor'}`}
class={`${$$props.class}`}
viewBox="0 0 512 512"
><path
d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z"
/></svg
>

After

Width:  |  Height:  |  Size: 470 B

View file

@ -0,0 +1,6 @@
export { default as IcnGitHub } from './IcnGitHub.svelte';
export { default as IcnMonero } from './IcnMonero.svelte';
export { default as IcnReddit } from './IcnReddit.svelte';
export { default as IcnTwitter } from './IcnTwitter.svelte';
export { default as IcnFacebook } from './IcnFacebook.svelte';
export { default as IcnTelegram } from './IcnTelegram.svelte';

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,17 @@
export const getDistinct = (items) => {
return Array.from(getCounter(items).keys());
};
export const getDuplicates = (items) => {
return Array.from(getCounter(items).entries())
.filter(([, count]) => count !== 1)
.map(([key]) => key);
};
export const getCounter = (items) => {
const result = new Map();
items.forEach((item) => {
result.set(item, (result.get(item) ?? 0) + 1);
});
return result;
};

View file

@ -0,0 +1,4 @@
/** @param {string} path */
export const apiUri = (path) => {
return `${import.meta.env.VITE_API_URL || ''}${path}`;
};

View file

@ -0,0 +1,76 @@
/**
* Modifies the input string based on whether it is an IPv6 address.
* If the input is an IPv6 address, it wraps it in square brackets `[ ]`.
* Otherwise, it returns the input string as-is (for domain names or
* IPv4 addresses). AND I'M SORRY USING REGEX FOR THIS!
*
* @param {string} hostname
* @returns {string} - The modified string, IPv6 addresses wrapped in `[ ]`.
*/
export const formatHostname = (hostname) => {
// const ipv6Pattern = /^(?:[a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4}$/; // full
// pattern for both full and compressed IPv6 addresses.
// source: https://regex101.com/library/cP9mH9?filterFlavors=dotnet&filterFlavors=javascript&orderBy=RELEVANCE&search=ip
// This may be incorrect, but let's assume it's correct. xD
const ipv6Pattern =
/^(([0-9A-Fa-f]{1,4}:){7})([0-9A-Fa-f]{1,4})$|(([0-9A-Fa-f]{1,4}:){1,6}:)(([0-9A-Fa-f]{1,4}:){0,4})([0-9A-Fa-f]{1,4})$/;
if (ipv6Pattern.test(hostname)) {
return `[${hostname}]`;
}
return hostname;
};
/**
* @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]}`;
};
/**
* Returns a number with a maximum precision.
*
* This function was copied from jtgrassie/monero-pool project.
* Source: https://github.com/jtgrassie/monero-pool/blob/master/src/webui-embed.html
*
* Copyright (c) 2018, The Monero Project
*
* @param {number} n
* @param {number} p
*/
const maxPrecision = (n, p) => {
return parseFloat(n.toFixed(p));
};
/**
* Formats a hash value (h) into human readable format.
*
* This function was copied from jtgrassie/monero-pool project.
* Source: https://github.com/jtgrassie/monero-pool/blob/master/src/webui-embed.html
*
* Copyright (c) 2018, The Monero Project
*
* @param {number} h
*/
export const 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';
};

View file

@ -0,0 +1,2 @@
export const prerender = true;
export const trailingSlash = 'always';

View file

@ -0,0 +1,85 @@
<script>
import '../app.css';
import { page } from '$app/stores';
import {
Toast,
Modal,
Drawer,
ProgressBar,
initializeStores,
storePopup
} from '@skeletonlabs/skeleton';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom';
import { MainNav, MobileDrawer } from '$lib/components/navigation';
import Footer from '$lib/components/Footer.svelte';
initializeStores();
storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow });
let isLoading = false;
beforeNavigate(() => (isLoading = true));
afterNavigate(() => {
isLoading = false;
});
/* prettier-ignore */
const meta = {
title: 'Monero Remote Node',
description: 'A website that helps you monitor your favourite Monero remote nodes, a device on the internet running the Monero software with copy of the Monero blockchain.',
keywords: 'monero,monero,xmr,monero node,xmrnode,cryptocurrency,monero remote node,monero testnet,monero stagenet'
};
page.subscribe((page) => {
if (typeof page.data.meta === 'object') {
meta.title = page.data.meta.title ?? meta.title;
meta.description = page.data.meta.description ?? meta.description;
meta.keywords = page.data.meta.keywords ?? meta.description;
}
});
</script>
<svelte:head>
<title>{meta.title} — xmr.ditatompel.com</title>
<!-- Meta Tags -->
<meta name="title" content="{meta.title} — xmr.ditatompel.com" />
<meta name="description" content={meta.description} />
<meta name="keywords" content={meta.keywords} />
<meta name="theme-color" content="#272b31" />
<meta name="author" content="ditatompel" />
<!-- Open Graph - https://ogp.me/ -->
<meta property="og:site_name" content="xmr.ditatompel.com" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://xmr.ditatompel.com{$page.url.pathname}" />
<meta property="og:locale" content="en_US" />
<meta property="og:title" content="{meta.title} — xmr.ditatompel.com" />
<meta property="og:description" content={meta.description} />
</svelte:head>
<Modal />
<Toast />
{#if isLoading}
<ProgressBar
class="fixed top-0 z-50"
height="h-1"
track="bg-opacity-100"
meter="bg-gradient-to-br from-purple-600 via-pink-600 to-blue-600"
/>
{/if}
<Drawer>
<h2 class="p-4">Navigation</h2>
<hr />
<MobileDrawer />
<hr />
</Drawer>
<MainNav />
<div class="pt-10 md:pt-12 min-h-screen">
<slot />
</div>
<Footer />

View file

@ -0,0 +1,37 @@
/** @type {import('./$types').PageLoad} */
export async function load() {
return {
meta: {
title: 'Monero Remote Node',
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'
},
links: [
{ text: 'moneroworld.com', uri: 'https://moneroworld.com' },
{ text: 'monero.how', uri: 'https://www.monero.how' },
{ text: 'monero.observer', uri: 'https://www.monero.observer' },
{ text: 'revuo-xmr.com', uri: 'https://revuo-xmr.com' },
{ text: 'themonoeromoon.com', uri: 'https://www.themoneromoon.com' },
{ text: 'monerotopia.com', uri: 'https://monerotopia.com' },
{ text: 'sethforprivacy.com', uri: 'https://sethforprivacy.com' }
],
stagenet: [
{ label: 'P2P', value: 'stagenet.xmr.ditatompel.com:38080', key: 'snetP2P' },
{ label: 'RPC', value: 'stagenet.xmr.ditatompel.com:38089', key: 'snetRPC' },
{ label: 'RPC SSL', value: 'stagenet.xmr.ditatompel.com:443', key: 'snetSSL' }
],
testnet: [
{ label: 'P2P', value: 'testnet.xmr.ditatompel.com:28080', key: 'tnetP2P' },
{ label: 'RPC', value: 'testnet.xmr.ditatompel.com:28089', key: 'tnetRPC' },
{ label: 'RPC SSL', value: 'testnet.xmr.ditatompel.com:443', key: 'tnetSSL' }
],
donation: {
// You change donation address and qr image below if you run your own "instance"
address:
'8BWYe6GzbNKbxe3D8mPkfFMQA2rViaZJFhWShhZTjJCNG6EZHkXRZCKHiuKmwwe4DXDYF8KKcbGkvNYaiRG3sNt7JhnVp7D',
qr: '/img/monerotip.png'
}
};
}

View file

@ -0,0 +1,187 @@
<script>
import { clipboard } from '@skeletonlabs/skeleton';
import {
IcnGitHub,
IcnMonero,
IcnReddit,
IcnTwitter,
IcnFacebook,
IcnTelegram
} from '$lib/components/svg';
import News from '$lib/components/News.svelte';
/** @type {import('./$types').PageData} */
export let data;
let donationCopied = false;
/** @param {Event & { target: HTMLInputElement }} e */
function copyHandler(e) {
e.target.disabled = true;
e.target.innerText = 'Copied 👍';
setTimeout(() => {
e.target.innerText = 'Copy';
e.target.disabled = false;
}, 1000);
}
function copyDonationAddr() {
donationCopied = true;
setTimeout(() => {
donationCopied = false;
}, 2500);
}
</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>{data.meta.description}</p>
<!-- prettier-ignore -->
<div class="pt-2">
<a href="https://www.getmonero.org" class="variant-ghost chip mt-2 hover:variant-filled" target="_blank" rel="noopener">
<span><IcnMonero class="h-4 w-4" /></span>
<span>GetMonero.org</span>
</a>
<a href="https://github.com/monero-project" class="variant-ghost chip mt-2 hover:variant-filled" target="_blank" rel="noopener">
<span><IcnGitHub fill="currentColor" class="h-4 w-4" /></span>
<span>monero-project</span>
</a>
<a href="https://www.reddit.com/r/Monero/" class="variant-ghost chip mt-2 hover:variant-filled" target="_blank" rel="noopener">
<span><IcnReddit fill="currentColor" class="h-4 w-4" /></span>
<span>r/Monero</span>
</a>
<a href="https://twitter.com/monero" class="variant-ghost chip mt-2 hover:variant-filled" target="_blank" rel="noopener">
<span><IcnTwitter fill="currentColor" class="h-4 w-4" /></span>
<span>@monero</span>
</a>
<a href="https://www.facebook.com/monerocurrency/" class="variant-ghost chip mt-2 hover:variant-filled" target="_blank" rel="noopener">
<span><IcnFacebook fill="currentColor" class="h-4 w-4" /></span>
<span>monerocurrency</span>
</a>
<a href="https://telegram.me/monero" class="variant-ghost chip mt-2 hover:variant-filled" target="_blank" rel="noopener">
<span><IcnTelegram fill="currentColor" class="h-4 w-4" /></span>
<span>monero</span>
</a>
</div>
</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">
<p>If you're new to Monero, the official links above is a perfect place to start.</p>
<p class="py-2">
Of course, there are lots of personal and community sites which generally discusses a lot
about Monero, such as
{#each data.links as link}
<a href={link.uri} class="external" target="_blank" rel="noopener">{link.text}</a>,&nbsp;
{/each} etc; can be an other good reference for you.
</p>
<p>You can find few resources I provide related to Monero below:</p>
</div>
<!-- prettier-ignore -->
<div class="section-container text-token grid grid-cols-1 gap-2 md:grid-cols-3">
<a class="card card-hover overflow-hidden py-2 text-center" href="/remote-nodes/">
<h2 class="h2 font-bold">Remote Nodes</h2>
<div class="space-y-4 p-4">
<p>List of submitted Monero remote nodes you can use when you <strong>cannot</strong> run your own node.</p>
</div>
</a>
<a class="card card-hover overflow-hidden py-2 text-center" href="/add-node/">
<h2 class="h2 font-bold">Add Node</h2>
<div class="space-y-4 p-4">
<p>Add your Monero public node to be monitored and see how it performs.</p>
</div>
</a>
<a class="card card-hover overflow-hidden py-2 text-center" href="https://monitor.ditatompel.com/d/xmr_metrics/monero-metrics?orgId=2" target="_blank" rel="noopener" >
<h2 class="h2 font-bold">Metrics</h2>
<div class="space-y-4 p-4">
<p>Collection of my Monero metrics (GitHub repository, blockchain, market, P2Pool) presented through Grafana. ↗</p>
</div>
</a>
</div>
</section>
<News />
<section id="my-monero-public-nodes" class="bg-surface-100-800-token">
<div class="section-container text-token grid grid-cols-1 gap-10 md:grid-cols-2">
<div class="text-center">
<h2 class="h2 pb-2 font-bold">My Stagenet Public Node</h2>
<p>
Stagenet is what you need to learn Monero safely. Stagenet is technically equivalent to
mainnet, both in terms of features and consensus rules.
</p>
{#each data.stagenet as { label, value, key }}
<div class="input-group input-group-divider my-2 grid-cols-[auto_1fr_auto]">
<div class="input-group-shim"><label for={key}>{label}</label></div>
<input class="text-center" type="text" id={key} name={key} {value} data-clipboard={key} />
<button
class="variant-filled-secondary"
use:clipboard={{ input: key }}
on:click={copyHandler}>Copy</button
>
</div>
{/each}
</div>
<div class="text-center">
<h2 class="h2 pb-2 font-bold">My Testnet Public Node</h2>
<p>
Testnet is the <em>"experimental"</em> network and blockchain where things get released long
before mainnet. As a normal user, use mainnet instead.
</p>
{#each data.testnet as { label, value, key }}
<div class="input-group input-group-divider my-2 grid-cols-[auto_1fr_auto]">
<div class="input-group-shim"><label for={key}>{label}</label></div>
<input class="text-center" type="text" id={key} name={key} {value} data-clipboard={key} />
<button
class="variant-filled-secondary"
use:clipboard={{ input: key }}
on:click={copyHandler}>Copy</button
>
</div>
{/each}
</div>
</div>
</section>
<section id="privacy-quote">
<div class="text-token mx-auto w-full max-w-4xl py-4 text-center">
<!-- prettier-ignore -->
<blockquote class="blockquote">
<p class="text-3xl">
Since we desire privacy, we must ensure that each party to a transaction have knowledge only of that which is directly necessary for that transaction.
</p>
<p class="my-2">
<strong>Eric Hughes</strong> in <a href="https://www.activism.net/cypherpunk/manifesto.html" class="external" target="_blank" rel="noopener"><cite title="Source Title">A Cypherpunk's Manifesto</cite></a>.
</p>
</blockquote>
</div>
</section>
<section id="monero-donation" class="section-container text-token text-center">
<div class="mx-auto flex w-full max-w-4xl flex-row items-center gap-10">
<div class="md:basis-3/4">
<label for="donate">If you like to buy me a coffee, here is my Monero address:</label>
<textarea class="textarea my-2" id="donate" name="donate" data-clipboard="donate" readonly
>{data.donation.address}</textarea
>
<button
class="variant-filled-success btn"
use:clipboard={{ input: 'donate' }}
disabled={donationCopied}
on:click={copyDonationAddr}
>{donationCopied ? 'Donation Address Copied! 🤩' : 'Copy Donation Address'}</button
>
</div>
<div class="md:basis-1/4">
<img src={data.donation.qr} alt="ditatompel's monero address" />
<p>Thank you so much! It means a lot to me. 🥰</p>
</div>
</div>
</section>

View file

@ -0,0 +1,12 @@
/** @type {import('./$types').PageLoad} */
export async function load() {
/* prettier-ignore */
return {
meta: {
title: 'Add Monero Node',
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'
}
};
}

View file

@ -0,0 +1,129 @@
<script>
import { invalidateAll, goto } from '$app/navigation';
import { apiUri } from '$lib/utils/common';
import { ProgressBar } from '@skeletonlabs/skeleton';
/** @type {import('./$types').PageData} */
export let data;
/** @type {ApiResponse} */
export let formResult;
let isProcessing = false;
/** @param {{ currentTarget: EventTarget & HTMLFormElement}} event */
async function handleSubmit(event) {
isProcessing = true;
const data = new FormData(event.currentTarget);
const response = await fetch(event.currentTarget.action, {
method: 'POST',
body: data
});
formResult = await response.json();
isProcessing = false;
if (formResult.status === 'ok') {
await invalidateAll();
goto('/remote-nodes');
}
}
</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>{data.meta.description}</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="page-info" class="mx-auto w-full max-w-4xl px-4 pb-7">
<div class="alert card shadow-xl">
<div class="alert-message">
<h2 class="h3 text-center">Important Note</h2>
<ul class="list-inside list-disc">
<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>
</section>
<section id="form-add-monero-node">
<div class="section-container text-center">
<p>Enter your Monero node information below (IPv6 host check is experimental):</p>
<form
class="mx-auto w-full max-w-3xl py-2"
action={apiUri('/api/v1/nodes')}
method="POST"
on:submit|preventDefault={handleSubmit}
>
<div class="grid grid-cols-1 gap-4 py-6 md:grid-cols-4">
<label class="label">
<span>Protocol *</span>
<select name="protocol" class="select variant-form-material" disabled={isProcessing}>
<option value="http">HTTP or TOR</option>
<option value="https">HTTPS</option>
</select>
</label>
<label class="label md:col-span-2">
<span>Host / IP *</span>
<input
class="input variant-form-material"
name="hostname"
type="text"
required
placeholder="Eg: node.example.com or 172.16.17.18"
disabled={isProcessing}
/>
</label>
<label class="label">
<span>Port *</span>
<input
class="input variant-form-material"
name="port"
type="number"
required
placeholder="Eg: 18081"
disabled={isProcessing}
/>
</label>
</div>
<button class="variant-filled-success btn" disabled={isProcessing}
>{isProcessing ? 'Processing...' : 'Submit'}</button
>
</form>
<div class="mx-auto w-full max-w-3xl py-2">
{#if !isProcessing}
{#if formResult?.status === 'error'}
<div class="mx-4 p-4 mb-4 text-sm rounded-lg bg-gray-700 text-red-400" role="alert">
<span class="font-medium">Error:</span>
{formResult.message}!
</div>
{/if}
{#if formResult?.status === 'ok'}
<div class="mx-4 p-4 mb-4 text-sm rounded-lg bg-gray-700 text-green-400" role="alert">
<span class="font-medium">Success:</span>
{formResult.message}!
</div>
{/if}
{:else}
<ProgressBar meter="bg-secondary-500" track="bg-secondary-500/30" value={undefined} />
<div class="mx-4 p-4 mb-4 text-sm rounded-lg bg-gray-700 text-blue-400" role="alert">
<span class="font-medium">Processing...</span>
</div>
{/if}
</div>
<p>
Here you can find list of <a class="anchor" href="/remote-nodes/">Monero Remote Node</a>.
</p>
</div>
</section>

View file

@ -0,0 +1,11 @@
/** @type {import('./$types').PageLoad} */
export async function load() {
return {
// prettier-ignore
meta: {
title: 'Public Monero Remote Nodes List',
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'
}
};
}

View file

@ -0,0 +1,343 @@
<script>
import { DataHandler } from '@vincjo/datatables/remote';
import { format, formatDistance } from 'date-fns';
import { loadData, loadFees, loadCountries } from './api-handler';
import { onMount } from 'svelte';
import {
DtSrRowsPerPage,
DtSrThSort,
DtSrThFilter,
DtSrRowCount,
DtSrPagination,
DtSrAutoRefresh
} from '$lib/components/datatables/server';
import {
HostPortCell,
NetTypeCell,
ProtocolCell,
CountryCellWithAsn,
StatusCell,
UptimeCell,
EstimateFeeCell
} from '$lib/components/datatables/xmr';
import News from '$lib/components/News.svelte';
export let data;
let filterNettype = 'any';
let filterProtocol = 'any';
let filterCc = 'any';
let filterStatus = -1;
let checkboxCors = false;
/** @type {{total_nodes: number, cc: string, name: string}[]} */
let countries = [];
let fees = [];
const handler = new DataHandler([], { rowsPerPage: 10, totalRows: 0 });
let rows = handler.getRows();
/** @type {Object.<string, number>} */
let majorityFee;
onMount(() => {
loadFees().then((data) => {
fees = data;
majorityFee = fees.reduce(
/**
* @param {Object.<string, number>} o
* @param {{ nettype: string, estimate_fee: number }} key
* @returns {Object.<string, number>}
*/
(o, key) => ({
...o,
[key.nettype]: key.estimate_fee
}),
{}
);
handler.onChange((state) => loadData(state));
handler.invalidate();
});
loadCountries().then((data) => {
countries = data;
});
});
</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>
<!-- prettier-ignore -->
<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>
<!-- prettier-ignore -->
<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 <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>
</section>
<News />
<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">
<DtSrAutoRefresh {handler} />
</div>
<div class="flex place-items-center">
<button
id="reloadDt"
name="reloadDt"
class="variant-filled-primary btn"
on:click={() => handler.invalidate()}>Reload</button
>
</div>
</div>
<table class="table table-hover table-compact w-full table-auto">
<thead>
<tr>
<th>Host:Port</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>
<tr>
<DtSrThFilter {handler} filterBy="host" placeholder="Filter Host / IP" />
<th>
<select
id="nettype"
name="nettype"
class="select variant-form-material"
bind:value={filterNettype}
on:change={() => {
handler.filter(filterNettype, 'nettype');
handler.invalidate();
}}
>
<option value="any">Any</option>
<option value="mainnet">MAINNET</option>
<option value="stagenet">STAGENET</option>
<option value="testnet">TESTNET</option>
</select>
</th>
<th>
<select
id="protocol"
name="protocol"
class="select variant-form-material"
bind:value={filterProtocol}
on:change={() => {
handler.filter(filterProtocol, 'protocol');
handler.invalidate();
}}
>
<option value="any">Any</option>
<option value="tor">TOR</option>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</th>
<th>
<select
id="cc"
name="cc"
class="select variant-form-material"
bind:value={filterCc}
on:change={() => {
handler.filter(filterCc, 'cc');
handler.invalidate();
}}
>
<option value="any">Any</option>
{#each countries as country}
{#if country.cc === ''}
<option value="UNKNOWN">UNKNOWN ({country.total_nodes})</option>
{:else}
<option value={country.cc}
>{country.name === '' ? country.cc : country.name} ({country.total_nodes})</option
>
{/if}
{/each}
</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="0">Offline</option>
<option value="1">Online</option>
</select>
</th>
<th colspan="2">
<label for="cors" class="flex items-center justify-center space-x-2">
<input
id="cors"
name="cors"
class="checkbox"
type="checkbox"
bind:checked={checkboxCors}
on:change={() => {
handler.filter(checkboxCors === true ? 1 : -1, 'cors');
handler.invalidate();
}}
/>
<p>CORS</p>
</label>
</th>
</tr>
</thead>
<tbody>
{#each $rows as row (row.id)}
<tr>
<td
><HostPortCell
ip_addresses={row.ip_addresses}
is_tor={row.is_tor}
hostname={row.hostname}
port={row.port}
ipv6_only={row.ipv6_only}
/>
</td>
<td><NetTypeCell nettype={row.nettype} height={row.height} /></td>
<td><ProtocolCell protocol={row.protocol} cors={row.cors} /></td>
<td
><CountryCellWithAsn
cc={row.cc}
country_name={row.country_name}
city={row.city}
asn={row.asn}
asn_name={row.asn_name}
/></td
>
<td
><StatusCell
is_available={row.is_available}
statuses={row.last_check_statuses}
/></td
>
<td>
<EstimateFeeCell
estimate_fee={row.estimate_fee}
majority_fee={majorityFee[row.nettype]}
/>
</td>
<td
><UptimeCell uptime={row.uptime} /><br />
<a
class="anchor !text-purple-800 dark:!text-purple-400"
href="/remote-nodes/logs/?node_id={row.id}">[Logs]</a
>
</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 mb-2">
<DtSrRowCount {handler} />
<DtSrPagination {handler} />
</div>
</div>
</div>
</section>
<section id="page-info" class="mx-auto w-full max-w-4xl px-4 pb-7">
<div class="alert card shadow-xl">
<div class="alert-message">
<h2 class="h3">Info</h2>
<ul class="list-inside list-disc">
<li>
If you find any remote nodes that are strange or suspicious, please <a
class="external"
href="https://github.com/ditatompel/xmr-remote-nodes/issues"
target="_blank"
rel="noopener">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-rose-900 font-bold">get_fee_estimate</code> RPC call method.
</li>
<li>
Malicious actors who running remote nodes <a
class="link"
href="/img/node-tx-fee.jpg"
rel="noopener">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 know one or more remote node that we don't currently monitor, please add them using <a
href="/add-node">this form</a
>.
</li>
<li>
I deliberately cut the long Tor addresses, click the <span
class="text-orange-800 dark:text-orange-300">👁 torhostname...</span
> to see the full Tor address.
</li>
<li>
You can found larger remote nodes database from <a
class="external"
href="https://monero.fail/"
role="button"
target="_blank"
rel="noopener">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
class="external"
href="https://insights.ditatompel.com/en/blog/2022/01/public-api-monero-remote-node-list/"
>Public API Monero Remote Node List</a
> blog post for more detailed information.
</li>
</ul>
</div>
</div>
</section>

View file

@ -0,0 +1,37 @@
import { apiUri } from '$lib/utils/common';
/**
* @typedef {import('@vincjo/datatables/remote').State} State
* @param {State} state - The state object from the data table.
*/
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 ?? [];
};
export const loadCountries = async () => {
const response = await fetch(apiUri('/api/v1/countries'));
const json = await response.json();
return json.data ?? [];
};
export const loadFees = async () => {
const response = await fetch(apiUri('/api/v1/fees'));
const json = await response.json();
return json.data ?? [];
};
/** @param {State} state - The state object from the data table. */
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

@ -0,0 +1,11 @@
/** @type {import('./$types').PageLoad} */
export async function load() {
/* prettier-ignore */
return {
meta: {
title: 'Probe Logs',
description: 'Monero RPC response frpm monitored remote nodes',
keywords: 'monero log,monero node log,monitoring monero log,monero,xmr,monero node,xmrnode,cryptocurrency'
}
};
}

View file

@ -0,0 +1,178 @@
<script>
import { DataHandler } from '@vincjo/datatables/remote';
import { format, formatDistance } from 'date-fns';
import { loadData, loadNodeInfo } from './api-handler';
import { onMount } from 'svelte';
import { formatHostname, formatHashes, formatBytes } from '$lib/utils/strings';
import {
DtSrRowsPerPage,
DtSrThSort,
DtSrThFilter,
DtSrRowCount,
DtSrPagination,
DtSrAutoRefresh
} from '$lib/components/datatables/server';
/** @param {number | null } runtime */
function parseRuntime(runtime) {
return runtime === null ? '' : runtime.toLocaleString(undefined) + 's';
}
export let data;
let pageId = '0';
let filterStatus = -1;
/** @type {MoneroNode | null} */
let nodeInfo;
const handler = new DataHandler([], { rowsPerPage: 10, totalRows: 0 });
let rows = handler.getRows();
onMount(() => {
pageId = new URLSearchParams(window.location.search).get('node_id') || '0';
loadNodeInfo(pageId).then((data) => {
nodeInfo = data;
});
handler.filter(pageId, 'node_id');
handler.onChange((state) => loadData(state));
handler.invalidate();
});
</script>
<header id="hero" class="hero-gradient py-7">
<div class="card text-token mx-auto flex w-fit justify-center p-4">
<ol class="breadcrumb">
<li class="crumb"><a class="link underline" href="/remote-nodes">Remote Nodes</a></li>
<li class="crumb-separator" aria-hidden="true">/</li>
<li>Logs</li>
</ol>
</div>
<div class="section-container text-center">
<h1 class="h1 pb-2 font-extrabold">{data.meta.title}</h1>
</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>
{#if nodeInfo === undefined}
<div class="section-container mx-auto w-full max-w-3xl text-center">
<p>Loading...</p>
</div>
{:else if nodeInfo === null}
<div class="section-container mx-auto w-full max-w-3xl text-center">
<p>Node ID does not exist</p>
</div>
{:else}
<div class="section-container">
<div class="table-container mx-auto w-full max-w-3xl">
<table class="table">
<tbody>
<tr>
<td class="font-bold">Hostname:Port</td>
<td>{formatHostname(nodeInfo?.hostname)}:{nodeInfo?.port}</td>
</tr>
<tr>
<td class="font-bold">Public IP</td>
<td>{nodeInfo?.ip_addresses.replace(/,/g, ', ')}</td>
</tr>
<tr>
<td class="font-bold">Net Type</td>
<td>{nodeInfo?.nettype.toUpperCase()}</td>
</tr></tbody
>
</table>
</div>
</div>
<section id="node-logs">
<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">
<DtSrAutoRefresh {handler} />
</div>
<div class="flex place-items-center">
<button
id="reloadDt"
name="reloadDt"
class="variant-filled-primary btn"
on:click={() => handler.invalidate()}>Reload</button
>
</div>
</div>
<table class="table table-hover table-compact w-full table-auto">
<thead>
<tr>
<th>#ID</th>
<th>Prober ID</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="3">
<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={7}
/>
</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>
{/if}

View file

@ -0,0 +1,32 @@
import { apiUri } from '$lib/utils/common';
/**
* @typedef {import('@vincjo/datatables/remote').State} State
* @param {State} state - The state object from the data table.
*/
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 {string} nodeId */
export const loadNodeInfo = async (nodeId) => {
const response = await fetch(apiUri(`/api/v1/nodes/id/${nodeId}`));
const json = await response.json();
return json.data;
};
/** @param {State} state - The state object from the data table. */
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

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View file

Before

Width:  |  Height:  |  Size: 200 B

After

Width:  |  Height:  |  Size: 200 B

View file

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

View file

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 553 B

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

Before

Width:  |  Height:  |  Size: 178 B

After

Width:  |  Height:  |  Size: 178 B

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View file

Before

Width:  |  Height:  |  Size: 137 B

After

Width:  |  Height:  |  Size: 137 B

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 311 B

View file

Before

Width:  |  Height:  |  Size: 251 B

After

Width:  |  Height:  |  Size: 251 B

View file

Before

Width:  |  Height:  |  Size: 516 B

After

Width:  |  Height:  |  Size: 516 B

View file

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 504 B

View file

Before

Width:  |  Height:  |  Size: 579 B

After

Width:  |  Height:  |  Size: 579 B

Some files were not shown because too many files have changed in this diff Show more