Copying my other project structure to this project
44
.air.toml
Normal file
|
@ -0,0 +1,44 @@
|
|||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 0
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "frontend/node_modules", "data", "bin"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "css", "js"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
21
.editorconfig
Normal file
|
@ -0,0 +1,21 @@
|
|||
; https://editorconfig.org/
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[{Makefile,go.mod,go.sum,*.go}]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
[*.md]
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
|
||||
[{*.yml,*.yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
17
.env.example
Normal file
|
@ -0,0 +1,17 @@
|
|||
SECRET_KEY="" # must be 32 char length, use `openssl rand -base64 32` to generate random secret
|
||||
LOG_LEVEL=INFO # can be DEBUG, INFO, WARNING, ERROR
|
||||
|
||||
# Fiber Config
|
||||
APP_DEBUG=false # if this set to true , LOG_LEVEL will be set to DEBUG
|
||||
APP_PREFORK=true
|
||||
APP_HOST="0.0.0.0"
|
||||
APP_PORT=18090
|
||||
APP_PROXY_HEADER="X-Real-Ip" # CF-Connecting-IP
|
||||
APP_ALLOW_ORIGIN="http://localhost:5173,http://192.168.1.99:5173,https://ditatompel.com"
|
||||
|
||||
# DB settings:
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=
|
||||
DB_NAME=wa_ditatombot
|
16
Makefile
Normal file
|
@ -0,0 +1,16 @@
|
|||
.PHONY: ui build linux64
|
||||
|
||||
BINARY_NAME = xmr-nodes
|
||||
|
||||
build: ui linux64
|
||||
|
||||
ui:
|
||||
go generate ./...
|
||||
|
||||
linux64:
|
||||
CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build -o bin/${BINARY_NAME}-static-linux-amd64
|
||||
|
||||
clean:
|
||||
go clean
|
||||
rm -rfv ./bin
|
||||
rm -rf ./frontend/build
|
80
cmd/admin.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/ditatompel/xmr-nodes/internal/database"
|
||||
"github.com/ditatompel/xmr-nodes/internal/repo"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var adminCmd = &cobra.Command{
|
||||
Use: "admin",
|
||||
Short: "Create Admin",
|
||||
Long: `Create an admin account for WebUI access.`,
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
if len(args) == 0 {
|
||||
fmt.Println("Usage: xmr-nodes admin create")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if args[0] == "create" {
|
||||
if err := database.ConnectDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := createAdmin(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("Admin account created")
|
||||
os.Exit(0)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(adminCmd)
|
||||
}
|
||||
|
||||
func createAdmin() error {
|
||||
admin := repo.NewAdminRepo(database.GetDB())
|
||||
a := repo.Admin{
|
||||
Username: stringPrompt("Username:"),
|
||||
Password: passPrompt("Password:"),
|
||||
}
|
||||
_, err := admin.CreateAdmin(&a)
|
||||
return err
|
||||
}
|
||||
|
||||
func stringPrompt(label string) string {
|
||||
var s string
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
fmt.Fprint(os.Stderr, label+" ")
|
||||
s, _ = r.ReadString('\n')
|
||||
if s != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func passPrompt(label string) string {
|
||||
var s string
|
||||
for {
|
||||
fmt.Fprint(os.Stderr, label+" ")
|
||||
b, _ := term.ReadPassword(int(syscall.Stdin))
|
||||
s = string(b)
|
||||
if s != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
return s
|
||||
}
|
31
cmd/root.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/ditatompel/xmr-nodes/internal/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const AppVer = "0.0.1"
|
||||
|
||||
var LogLevel string
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "xmr-nodes",
|
||||
Short: "XMR Nodes",
|
||||
Version: AppVer,
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.LoadAll(".env")
|
||||
LogLevel = config.AppCfg().LogLevel
|
||||
}
|
104
cmd/serve.go
Normal file
|
@ -0,0 +1,104 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/ditatompel/xmr-nodes/frontend"
|
||||
"github.com/ditatompel/xmr-nodes/handler"
|
||||
"github.com/ditatompel/xmr-nodes/internal/config"
|
||||
"github.com/ditatompel/xmr-nodes/internal/database"
|
||||
"github.com/ditatompel/xmr-nodes/internal/repo"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/encryptcookie"
|
||||
"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"
|
||||
)
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Serve the WebUI",
|
||||
Long: `This command will run HTTP server for APIs and WebUI.`,
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
serve()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
|
||||
func serve() {
|
||||
appCfg := config.AppCfg()
|
||||
// connect to DB
|
||||
if err := database.ConnectDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Define Fiber config & app.
|
||||
app := fiber.New(fiberConfig())
|
||||
|
||||
// recover
|
||||
app.Use(recover.New(recover.Config{EnableStackTrace: appCfg.Debug}))
|
||||
|
||||
// logger middleware
|
||||
if appCfg.Debug {
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "[${time}] ${status} - ${latency} ${method} ${path} ${queryParams} ${ip} ${ua}\n",
|
||||
}))
|
||||
}
|
||||
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: appCfg.AllowOrigin,
|
||||
AllowHeaders: "Origin, Content-Type, Accept",
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// cookie
|
||||
app.Use(encryptcookie.New(encryptcookie.Config{Key: appCfg.SecretKey}))
|
||||
|
||||
handler.AppRoute(app)
|
||||
handler.V1Api(app)
|
||||
app.Use("/", filesystem.New(filesystem.Config{
|
||||
Root: frontend.SvelteKitHandler(),
|
||||
// NotFoundFile: "index.html",
|
||||
}))
|
||||
|
||||
// signal channel to capture system calls
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
|
||||
|
||||
// start a cleanup cron-job
|
||||
if !fiber.IsChild() {
|
||||
cronRepo := repo.NewCron(database.GetDB())
|
||||
go cronRepo.RunCronProcess()
|
||||
}
|
||||
|
||||
// start shutdown goroutine
|
||||
go func() {
|
||||
// capture sigterm and other system call here
|
||||
<-sigCh
|
||||
fmt.Println("Shutting down HTTP server...")
|
||||
_ = app.Shutdown()
|
||||
}()
|
||||
|
||||
// start http server
|
||||
serverAddr := fmt.Sprintf("%s:%d", appCfg.Host, appCfg.Port)
|
||||
if err := app.Listen(serverAddr); err != nil {
|
||||
fmt.Printf("Server is not running! error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fiberConfig() fiber.Config {
|
||||
return fiber.Config{
|
||||
Prefork: config.AppCfg().Prefork,
|
||||
ProxyHeader: config.AppCfg().ProxyHeader,
|
||||
AppName: "ditatompel's XMR Nodes HTTP server " + AppVer,
|
||||
}
|
||||
}
|
13
frontend/.eslintignore
Normal file
|
@ -0,0 +1,13 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
15
frontend/.eslintrc.cjs
Normal file
|
@ -0,0 +1,15 @@
|
|||
/** @type { import("eslint").Linter.Config } */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['eslint:recommended', 'plugin:svelte/recommended', 'prettier'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: ['.svelte']
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
}
|
||||
};
|
10
frontend/.gitignore
vendored
Normal 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
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
4
frontend/.prettierignore
Normal 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
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
35
frontend/README.md
Normal file
|
@ -0,0 +1,35 @@
|
|||
# UI
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
|
||||
|
||||
## Deploying
|
||||
|
||||
after running `npm run build` from development device, copy `./build`, `package.json` and `package-lock.json` to server. On the server, run `npm ci --omit dev` then restart the systemd service.
|
||||
|
||||
Playbook example (run from root project):
|
||||
```shell
|
||||
ansible-playbook -i ./utils/ansible/inventory.ini -l production ./utils/ansible/deploy.yml -K
|
||||
```
|
21
frontend/embed.go
Normal file
|
@ -0,0 +1,21 @@
|
|||
package frontend
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:generate npm i
|
||||
//go:generate npm run build
|
||||
//go:embed all: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)
|
||||
}
|
18
frontend/jsconfig.json
Normal 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
|
||||
}
|
4070
frontend/package-lock.json
generated
Normal file
40
frontend/package.json
Normal file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "xmr-nodes-frontend",
|
||||
"version": "0.0.1",
|
||||
"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.3",
|
||||
"@skeletonlabs/skeleton": "^2.9.0",
|
||||
"@skeletonlabs/tw-plugin": "^0.3.1",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@types/eslint": "^8.56.0",
|
||||
"@vincjo/datatables": "^1.14.5",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"date-fns": "^3.3.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
6
frontend/postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
13
frontend/src/app.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind variants;
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
@apply bg-surface-50-900-token rounded-lg border-2 border-dashed border-gray-200 p-4 dark:border-gray-700;
|
||||
}
|
16
frontend/src/app.d.ts
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
13
frontend/src/app.html
Normal 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>
|
50
frontend/src/lib/components/InfiniteScroll.svelte
Normal file
|
@ -0,0 +1,50 @@
|
|||
<script>
|
||||
import { onDestroy, createEventDispatcher } from 'svelte';
|
||||
|
||||
export let threshold = 0;
|
||||
export let horizontal = false;
|
||||
export let hasMore = true;
|
||||
/** @type {any} */
|
||||
let elementScroll;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let isLoadMore = false;
|
||||
/** @type {any} */
|
||||
let component;
|
||||
|
||||
$: {
|
||||
if (component || elementScroll) {
|
||||
const element = elementScroll ? elementScroll : component.parentNode;
|
||||
|
||||
element.addEventListener('scroll', onScroll);
|
||||
element.addEventListener('resize', onScroll);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {any} e */
|
||||
const onScroll = (e) => {
|
||||
const offset = horizontal
|
||||
? e.target.scrollWidth - e.target.clientWidth - e.target.scrollLeft
|
||||
: e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop;
|
||||
|
||||
if (offset <= threshold) {
|
||||
if (!isLoadMore && hasMore) {
|
||||
dispatch('loadMore');
|
||||
}
|
||||
isLoadMore = true;
|
||||
} else {
|
||||
isLoadMore = false;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
if (component || elementScroll) {
|
||||
const element = elementScroll ? elementScroll : component.parentNode;
|
||||
|
||||
element.removeEventListener('scroll', null);
|
||||
element.removeEventListener('resize', null);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={component} class="w-0" />
|
|
@ -0,0 +1,69 @@
|
|||
<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')}> ❮ </button>
|
||||
<button class="mx-4">page <b>{$pageNumber}</b></button>
|
||||
<button type="button" class="sm-btn" on:click={() => setPage('next')}>❯</button>
|
||||
{:else}
|
||||
<div class="lg:hidden">
|
||||
<button type="button" class="sm-btn" on:click={() => setPage('previous')}> ❮ </button>
|
||||
<button class="mx-4">page <b>{$pageNumber}</b></button>
|
||||
<button
|
||||
class="sm-btn"
|
||||
class:disabled={$pageNumber === $pageCount}
|
||||
on:click={() => setPage('next')}
|
||||
>
|
||||
❯
|
||||
</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')}>❮</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')}
|
||||
>
|
||||
❯
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style lang="postcss">
|
||||
.sm-btn {
|
||||
@apply btn btn-sm variant-ghost-surface hover:variant-soft-secondary;
|
||||
}
|
||||
.disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
</style>
|
|
@ -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 />
|
||||
{: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}
|
|
@ -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>
|
|
@ -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}
|
||||
/>
|
|
@ -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>
|
|
@ -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>
|
6
frontend/src/lib/components/datatables/server/index.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
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';
|
|
@ -0,0 +1,24 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { navs } from './navs';
|
||||
import { getDrawerStore } from '@skeletonlabs/skeleton';
|
||||
|
||||
const drawerStore = getDrawerStore();
|
||||
|
||||
function drawerClose() {
|
||||
drawerStore.close();
|
||||
}
|
||||
|
||||
$: classesActive = (/** @type {string} */ href) =>
|
||||
$page.url.pathname.startsWith(href) ? 'bg-primary-500' : '';
|
||||
</script>
|
||||
|
||||
<nav class="list-nav p-4">
|
||||
<ul>
|
||||
{#each navs as nav}
|
||||
<li>
|
||||
<a href={nav.path} class={classesActive(nav.path)} on:click={drawerClose}>{nav.name}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
85
frontend/src/lib/components/navigation/AdminNav.svelte
Normal file
|
@ -0,0 +1,85 @@
|
|||
<script>
|
||||
import { invalidateAll, goto } from '$app/navigation';
|
||||
import { LightSwitch, getDrawerStore } from '@skeletonlabs/skeleton';
|
||||
import { apiUri } from '$lib/utils/common';
|
||||
|
||||
const drawerStore = getDrawerStore();
|
||||
function drawerOpen() {
|
||||
drawerStore.open({});
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef formResult
|
||||
* @type {object}
|
||||
* @property {string} status
|
||||
* @property {string} message
|
||||
* @property {null | object} data
|
||||
*/
|
||||
/** @type {formResult} */
|
||||
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') {
|
||||
// rerun all `load` functions, following the successful update
|
||||
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={drawerOpen}
|
||||
>
|
||||
<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/dashboard/" 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>
|
34
frontend/src/lib/components/navigation/AdminSidebar.svelte
Normal file
|
@ -0,0 +1,34 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { navs } 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 navs 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>
|
3
frontend/src/lib/components/navigation/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as AdminNav } from './AdminNav.svelte';
|
||||
export { default as AdminSidebar } from './AdminSidebar.svelte';
|
||||
export { default as AdminMobileDrawer } from './AdminMobileDrawer.svelte';
|
5
frontend/src/lib/components/navigation/navs.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
export const navs = [
|
||||
{ name: 'Dashboard', path: '/app/dashboard/' },
|
||||
{ name: 'Prober', path: '/app/prober/' },
|
||||
{ name: 'Crons', path: '/app/crons/' }
|
||||
];
|
1
frontend/src/lib/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
4
frontend/src/lib/utils/common.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
/** @param {string} path */
|
||||
export const apiUri = (path) => {
|
||||
return `${import.meta.env.VITE_API_URL || ''}${path}`;
|
||||
};
|
21
frontend/src/routes/(loggedin)/+layout.svelte
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script>
|
||||
import { Toast, Drawer, Modal } from '@skeletonlabs/skeleton';
|
||||
import { AdminNav, AdminSidebar, AdminMobileDrawer } from '$lib/components/navigation';
|
||||
</script>
|
||||
|
||||
<Toast />
|
||||
<Modal />
|
||||
|
||||
<Drawer>
|
||||
<h2 class="p-4">Navigation</h2>
|
||||
<hr />
|
||||
<AdminMobileDrawer />
|
||||
<hr />
|
||||
</Drawer>
|
||||
|
||||
<AdminNav />
|
||||
<AdminSidebar />
|
||||
|
||||
<div class="min-h-screen bg-gray-100/80 p-4 pt-14 dark:bg-gray-900/80 sm:ml-64">
|
||||
<slot />
|
||||
</div>
|
174
frontend/src/routes/(loggedin)/app/crons/+page.svelte
Normal file
|
@ -0,0 +1,174 @@
|
|||
<script>
|
||||
import { DataHandler } from '@vincjo/datatables/remote';
|
||||
import { format, formatDistance } from 'date-fns';
|
||||
import { loadData } from './api-handler';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { DtSrThSort, DtSrThFilter, DtSrRowCount } from '$lib/components/datatables/server';
|
||||
|
||||
const handler = new DataHandler([], { rowsPerPage: 1000, totalRows: 0 });
|
||||
let rows = handler.getRows();
|
||||
|
||||
const reloadData = () => {
|
||||
handler.invalidate();
|
||||
};
|
||||
|
||||
/** @type {string | number} */
|
||||
let filterState = -1;
|
||||
/** @type {string | number} */
|
||||
let filterEnabled = -1;
|
||||
|
||||
/** @type {number | undefined} */
|
||||
let intervalId;
|
||||
let intervalValue = 0;
|
||||
|
||||
const intervalOptions = [
|
||||
{ value: 0, label: 'No' },
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 10, label: '10s' },
|
||||
{ value: 30, label: '30s' },
|
||||
{ value: 60, label: '1m' }
|
||||
];
|
||||
|
||||
const startInterval = () => {
|
||||
const seconds = intervalValue;
|
||||
if (isNaN(seconds) || seconds < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!intervalOptions.some((option) => option.value === seconds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
if (seconds > 0) {
|
||||
reloadData();
|
||||
intervalId = setInterval(() => {
|
||||
reloadData();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
};
|
||||
|
||||
$: startInterval(); // Automatically start the interval on change
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(intervalId); // Clear the interval when the component is destroyed
|
||||
});
|
||||
onMount(() => {
|
||||
handler.onChange((state) => loadData(state));
|
||||
handler.invalidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mb-4">
|
||||
<h1 class="h2 font-extrabold dark:text-white">Crons</h1>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="flex justify-between">
|
||||
<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>
|
||||
|
||||
<div class="my-2 overflow-x-auto">
|
||||
<table class="table table-hover table-compact w-full table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<DtSrThSort {handler} orderBy="id">ID</DtSrThSort>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Description</th>
|
||||
<DtSrThSort {handler} orderBy="run_every">Run Every</DtSrThSort>
|
||||
<DtSrThSort {handler} orderBy="last_run">Last Run</DtSrThSort>
|
||||
<DtSrThSort {handler} orderBy="next_run">Next Run</DtSrThSort>
|
||||
<DtSrThSort {handler} orderBy="run_time">Run Time</DtSrThSort>
|
||||
<th>State</th>
|
||||
<th>Enabled</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<DtSrThFilter {handler} filterBy="title" placeholder="Title" colspan={3} />
|
||||
<DtSrThFilter {handler} filterBy="description" placeholder="Description" colspan={5} />
|
||||
<th>
|
||||
<select
|
||||
id="fState"
|
||||
name="fState"
|
||||
class="select variant-form-material"
|
||||
bind:value={filterState}
|
||||
on:change={() => {
|
||||
handler.filter(filterState, 'cron_state');
|
||||
reloadData();
|
||||
}}
|
||||
>
|
||||
<option value={-1}>Any</option>
|
||||
<option value={1}>Running</option>
|
||||
<option value={0}>Idle</option>
|
||||
</select>
|
||||
</th>
|
||||
<th>
|
||||
<select
|
||||
id="fEnabled"
|
||||
name="fEnabled"
|
||||
class="select variant-form-material"
|
||||
bind:value={filterEnabled}
|
||||
on:change={() => {
|
||||
handler.filter(filterEnabled, 'is_enabled');
|
||||
reloadData();
|
||||
}}
|
||||
>
|
||||
<option value={-1}>Any</option>
|
||||
<option value={1}>Yes</option>
|
||||
<option value={0}>No</option>
|
||||
</select>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $rows as row (row.id)}
|
||||
<tr>
|
||||
<td>{row.id}</td>
|
||||
<td>{row.title}</td>
|
||||
<td>{row.slug}</td>
|
||||
<td>{row.description}</td>
|
||||
<td>{row.run_every}s</td>
|
||||
<td>
|
||||
{format(row.last_run * 1000, 'PP HH:mm')}<br />
|
||||
{formatDistance(row.last_run * 1000, new Date(), { addSuffix: true })}
|
||||
</td>
|
||||
<td>
|
||||
{format(row.next_run * 1000, 'PP HH:mm')}<br />
|
||||
{formatDistance(row.next_run * 1000, new Date(), { addSuffix: true })}
|
||||
</td>
|
||||
<td>{row.run_time}</td>
|
||||
<td>{row.cron_state ? 'RUNNING' : 'IDLE'}</td>
|
||||
<td>{row.is_enabled ? 'ENABLED' : 'DISABLED'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between mb-2">
|
||||
<DtSrRowCount {handler} />
|
||||
</div>
|
||||
</div>
|
21
frontend/src/routes/(loggedin)/app/crons/api-handler.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
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/crons?${getParams(state)}`));
|
||||
const json = await response.json();
|
||||
state.setTotalRows(json.data.length ?? 0);
|
||||
return json.data ?? [];
|
||||
};
|
||||
|
||||
const getParams = ({ pageNumber, rowsPerPage, sort, filters }) => {
|
||||
let params = `page=${pageNumber}&limit=${rowsPerPage}`;
|
||||
|
||||
if (sort) {
|
||||
params += `&orderBy=${sort.orderBy}&orderDir=${sort.direction}`;
|
||||
}
|
||||
if (filters) {
|
||||
params += filters.map(({ filterBy, value }) => `&${filterBy}=${value}`).join('');
|
||||
}
|
||||
return params;
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
<div class="mb-4">
|
||||
<h1 class="h2 font-extrabold dark:text-white">Dashboard</h1>
|
||||
</div>
|
120
frontend/src/routes/(loggedin)/app/prober/+page.svelte
Normal file
|
@ -0,0 +1,120 @@
|
|||
<script>
|
||||
import { DataHandler } from '@vincjo/datatables/remote';
|
||||
import { format, formatDistance } from 'date-fns';
|
||||
import { loadData } from './api-handler';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { DtSrThSort, DtSrThFilter, DtSrRowCount } from '$lib/components/datatables/server';
|
||||
|
||||
const handler = new DataHandler([], { rowsPerPage: 10, totalRows: 0 });
|
||||
let rows = handler.getRows();
|
||||
|
||||
const reloadData = () => {
|
||||
handler.invalidate();
|
||||
};
|
||||
|
||||
/** @type {number | undefined} */
|
||||
let intervalId;
|
||||
let intervalValue = 0;
|
||||
|
||||
const intervalOptions = [
|
||||
{ value: 0, label: 'No' },
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 10, label: '10s' },
|
||||
{ value: 30, label: '30s' },
|
||||
{ value: 60, label: '1m' }
|
||||
];
|
||||
|
||||
const startInterval = () => {
|
||||
const seconds = intervalValue;
|
||||
if (isNaN(seconds) || seconds < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!intervalOptions.some((option) => option.value === seconds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
if (seconds > 0) {
|
||||
reloadData();
|
||||
intervalId = setInterval(() => {
|
||||
reloadData();
|
||||
}, seconds * 1000);
|
||||
}
|
||||
};
|
||||
|
||||
$: startInterval(); // Automatically start the interval on change
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(intervalId); // Clear the interval when the component is destroyed
|
||||
});
|
||||
onMount(() => {
|
||||
handler.onChange((state) => loadData(state));
|
||||
handler.invalidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mb-4">
|
||||
<h1 class="h2 font-extrabold dark:text-white">Prober</h1>
|
||||
<a class="variant-filled-success btn btn-sm mb-4" href="/app/prober/add">Add Prober</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="flex justify-between">
|
||||
<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>
|
||||
|
||||
<div class="my-2 overflow-x-auto">
|
||||
<table class="table table-hover table-compact w-full table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<DtSrThSort {handler} orderBy="id">ID</DtSrThSort>
|
||||
<th>Name</th>
|
||||
<th>API Key</th>
|
||||
<DtSrThSort {handler} orderBy="last_submit_ts">Last Submit</DtSrThSort>
|
||||
</tr>
|
||||
<tr>
|
||||
<DtSrThFilter {handler} filterBy="name" placeholder="Name" colspan={2} />
|
||||
<DtSrThFilter {handler} filterBy="api_key" placeholder="API Key" colspan={2} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $rows as row (row.id)}
|
||||
<tr>
|
||||
<td>{row.id}</td>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.api_key}</td>
|
||||
<td>
|
||||
{format(row.last_submit_ts * 1000, 'PP HH:mm')}<br />
|
||||
{formatDistance(row.last_submit_ts * 1000, new Date(), { addSuffix: true })}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between mb-2">
|
||||
<DtSrRowCount {handler} />
|
||||
</div>
|
||||
</div>
|
94
frontend/src/routes/(loggedin)/app/prober/add/+page.svelte
Normal file
|
@ -0,0 +1,94 @@
|
|||
<script>
|
||||
import { invalidateAll, goto } from '$app/navigation';
|
||||
import { apiUri } from '$lib/utils/common';
|
||||
import { ProgressBar } from '@skeletonlabs/skeleton';
|
||||
|
||||
/**
|
||||
* @typedef formResult
|
||||
* @type {object}
|
||||
* @property {string} status
|
||||
* @property {string} message
|
||||
* @property {null | object} data
|
||||
*/
|
||||
/** @type {formResult} */
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: JSON.stringify(Object.fromEntries(data))
|
||||
});
|
||||
|
||||
formResult = await response.json();
|
||||
isProcessing = false;
|
||||
|
||||
if (formResult.status === 'ok') {
|
||||
// rerun all `load` functions, following the successful update
|
||||
await invalidateAll();
|
||||
goto('/app/prober/');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-4">
|
||||
<h1 class="h2 font-extrabold dark:text-white">Add Prober</h1>
|
||||
</div>
|
||||
{#if !isProcessing}
|
||||
{#if formResult?.status === 'error'}
|
||||
<div class="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="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 class="dashboard-card">
|
||||
<form
|
||||
class="space-y-4 md:space-y-6"
|
||||
action={apiUri('/api/v1/prober')}
|
||||
method="POST"
|
||||
on:submit|preventDefault={handleSubmit}
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="name" class="label">
|
||||
<span>Name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Prober name"
|
||||
autocomplete="off"
|
||||
class="input variant-form-material"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-primary-600 px-5 py-2.5 text-center text-sm font-medium hover:bg-primary-700"
|
||||
>Submit</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
21
frontend/src/routes/(loggedin)/app/prober/api-handler.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
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/prober?${getParams(state)}`));
|
||||
const json = await response.json();
|
||||
state.setTotalRows(json.data.length ?? 0);
|
||||
return json.data.items ?? [];
|
||||
};
|
||||
|
||||
const getParams = ({ pageNumber, rowsPerPage, sort, filters }) => {
|
||||
let params = `page=${pageNumber}&limit=${rowsPerPage}`;
|
||||
|
||||
if (sort) {
|
||||
params += `&orderBy=${sort.orderBy}&orderDir=${sort.direction}`;
|
||||
}
|
||||
if (filters) {
|
||||
params += filters.map(({ filterBy, value }) => `&${filterBy}=${value}`).join('');
|
||||
}
|
||||
return params;
|
||||
};
|
2
frontend/src/routes/+layout.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
export const prerender = true
|
||||
export const trailingSlash = 'always';
|
34
frontend/src/routes/+layout.svelte
Normal file
|
@ -0,0 +1,34 @@
|
|||
<script>
|
||||
// import { base } from '$app/paths';
|
||||
import '../app.css';
|
||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom'
|
||||
import {
|
||||
ProgressBar,
|
||||
initializeStores,
|
||||
storePopup // PopUps
|
||||
} from '@skeletonlabs/skeleton';
|
||||
|
||||
initializeStores();
|
||||
|
||||
// popups
|
||||
storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow });
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
// progress bar show
|
||||
beforeNavigate(() => (isLoading = true));
|
||||
|
||||
afterNavigate((/* params */) => {
|
||||
isLoading = false;
|
||||
});
|
||||
</script>
|
||||
{#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}
|
||||
<slot />
|
7
frontend/src/routes/+page.svelte
Normal file
|
@ -0,0 +1,7 @@
|
|||
<div class="w-full h-full flex justify-center items-center">
|
||||
<div class="text-center space-y-4">
|
||||
<h1 class="h1">( . ) ( . )</h1>
|
||||
<p>WAT?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
118
frontend/src/routes/login/+page.svelte
Normal file
|
@ -0,0 +1,118 @@
|
|||
<script>
|
||||
import { invalidateAll, goto } from '$app/navigation';
|
||||
import { apiUri } from '$lib/utils/common';
|
||||
import { ProgressBar, LightSwitch } from '@skeletonlabs/skeleton';
|
||||
|
||||
/**
|
||||
* @typedef formResult
|
||||
* @type {object}
|
||||
* @property {string} status
|
||||
* @property {string} message
|
||||
* @property {null | object} data
|
||||
*/
|
||||
/** @type {formResult} */
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: JSON.stringify(Object.fromEntries(data))
|
||||
});
|
||||
|
||||
formResult = await response.json();
|
||||
isProcessing = false;
|
||||
|
||||
if (formResult.status === 'ok') {
|
||||
// rerun all `load` functions, following the successful update
|
||||
await invalidateAll();
|
||||
goto('/app/dashboard/');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="bg-gray-50 dark:bg-gray-900">
|
||||
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
|
||||
<a href="/" class="flex items-center mb-6 text-2xl font-semibold text-gray-900 dark:text-white"
|
||||
>XMR Nodes</a
|
||||
>
|
||||
<div
|
||||
class="w-full rounded-lg shadow border md:mt-0 sm:max-w-md xl:p-0 bg-white border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
|
||||
<h1
|
||||
class="text-xl font-bold leading-tight tracking-tight tmd:text-2xl text-gray-900 dark:text-white"
|
||||
>
|
||||
Sign in to your account
|
||||
</h1>
|
||||
<form
|
||||
class="space-y-4 md:space-y-6"
|
||||
action={apiUri('/auth/login')}
|
||||
method="POST"
|
||||
on:submit|preventDefault={handleSubmit}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="username"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Username</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
class="input"
|
||||
placeholder="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder="••••••••"
|
||||
class="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn variant-filled-primary w-full">Sign in</button>
|
||||
|
||||
<LightSwitch />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#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>
|
||||
</div>
|
||||
</section>
|
BIN
frontend/static/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
frontend/static/favicon.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/static/img/icon/android-icon-144x144.png
Normal file
After Width: | Height: | Size: 5.3 KiB |
BIN
frontend/static/img/icon/android-icon-192x192.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
frontend/static/img/icon/android-icon-36x36.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/static/img/icon/android-icon-48x48.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/static/img/icon/android-icon-512x512.png
Normal file
After Width: | Height: | Size: 5.9 KiB |
BIN
frontend/static/img/icon/android-icon-72x72.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/static/img/icon/android-icon-96x96.png
Normal file
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/static/img/icon/apple-icon-180x180.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
BIN
frontend/static/img/icon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/static/img/icon/maskable-512x512.png
Normal file
After Width: | Height: | Size: 19 KiB |
63
frontend/static/manifest.json
Normal file
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"name": "xmr.ditatompel.com",
|
||||
"short_name": "xmr-nodes",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#272b31",
|
||||
"theme_color": "#272b31",
|
||||
"description": "WA Bot UI",
|
||||
"icons": [{
|
||||
"src": "/img/icon/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image/png",
|
||||
"purpose": "any",
|
||||
"density": "0.75"
|
||||
}, {
|
||||
"src": "/img/icon/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image/png",
|
||||
"purpose": "any",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "any",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "any",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any",
|
||||
"density": "4.0"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon/android-icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any",
|
||||
"density": "4.0"
|
||||
},
|
||||
{
|
||||
"src": "/img/icon/maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}]
|
||||
}
|
2
frontend/static/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
16
frontend/svelte.config.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
// paths: {
|
||||
// base: '/'
|
||||
// },
|
||||
// trailingSlash: 'always',
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
23
frontend/tailwind.config.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { join } from 'path';
|
||||
import { skeleton } from '@skeletonlabs/tw-plugin';
|
||||
import forms from '@tailwindcss/forms';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./src/**/*.{html,js,svelte,ts}',
|
||||
join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')
|
||||
],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: [
|
||||
forms,
|
||||
skeleton({
|
||||
themes: {
|
||||
preset: ['skeleton']
|
||||
}
|
||||
})
|
||||
]
|
||||
};
|
BIN
frontend/tmp/main
Normal file
9
frontend/vite.config.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
// @ts-check
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
]
|
||||
});
|
31
go.mod
Normal file
|
@ -0,0 +1,31 @@
|
|||
module github.com/ditatompel/xmr-nodes
|
||||
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
github.com/alexedwards/argon2id v1.0.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/gofiber/fiber/v2 v2.52.4
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/spf13/cobra v1.8.0
|
||||
golang.org/x/term v0.19.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.17.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
)
|
93
go.sum
Normal file
|
@ -0,0 +1,93 @@
|
|||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM=
|
||||
github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
18
handler/middlewares.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func CookieProtected(c *fiber.Ctx) error {
|
||||
cookie := c.Cookies("xmr-nodes-ui")
|
||||
if cookie == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": "Unauthorized",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
132
handler/response.go
Normal file
|
@ -0,0 +1,132 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ditatompel/xmr-nodes/internal/database"
|
||||
"github.com/ditatompel/xmr-nodes/internal/repo"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func Login(c *fiber.Ctx) error {
|
||||
payload := repo.Admin{}
|
||||
if err := c.BodyParser(&payload); err != nil {
|
||||
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
|
||||
repo := repo.NewAdminRepo(database.GetDB())
|
||||
res, err := repo.Login(payload.Username, payload.Password)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
|
||||
token := fmt.Sprintf("auth_%d_%d", res.Id, time.Now().Unix())
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "xmr-nodes-ui",
|
||||
Value: token,
|
||||
Expires: time.Now().Add(time.Hour * 24),
|
||||
HTTPOnly: true,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"message": "Logged in",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
|
||||
func Logout(c *fiber.Ctx) error {
|
||||
c.Cookie(&fiber.Cookie{
|
||||
Name: "xmr-nodes-ui",
|
||||
Value: "",
|
||||
Expires: time.Now(),
|
||||
HTTPOnly: true,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"message": "Logged out",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
|
||||
func Prober(c *fiber.Ctx) error {
|
||||
proberRepo := repo.NewProberRepo(database.GetDB())
|
||||
|
||||
if c.Method() == "POST" {
|
||||
payload := repo.Prober{}
|
||||
if err := c.BodyParser(&payload); err != nil {
|
||||
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
if payload.Name == "" {
|
||||
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": "Please fill prober name",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
err := proberRepo.AddProber(payload.Name)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"status": "error",
|
||||
"message": err.Error(),
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
query := repo.ProbersQueryParams{
|
||||
RowsPerPage: c.QueryInt("limit", 10),
|
||||
Page: c.QueryInt("page", 1),
|
||||
Name: c.Query("name"),
|
||||
ApiKey: c.Query("api_key"),
|
||||
}
|
||||
|
||||
prober, err := proberRepo.Probers(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": prober,
|
||||
})
|
||||
}
|
||||
|
||||
func Crons(c *fiber.Ctx) error {
|
||||
cronRepo := repo.NewCron(database.GetDB())
|
||||
|
||||
crons, err := cronRepo.Crons()
|
||||
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": "Crons",
|
||||
"data": crons,
|
||||
})
|
||||
}
|
18
handler/routes.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func AppRoute(app *fiber.App) {
|
||||
app.Post("/auth/login", Login)
|
||||
app.Post("/auth/logout", Logout)
|
||||
}
|
||||
|
||||
func V1Api(app *fiber.App) {
|
||||
v1 := app.Group("/api/v1")
|
||||
|
||||
v1.Get("/prober", Prober)
|
||||
v1.Post("/prober", Prober)
|
||||
v1.Get("/crons", Crons)
|
||||
}
|
41
internal/config/app.go
Normal file
|
@ -0,0 +1,41 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Debug bool
|
||||
Prefork bool
|
||||
Host string
|
||||
Port int
|
||||
ProxyHeader string
|
||||
AllowOrigin string
|
||||
SecretKey string
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
var app = &App{}
|
||||
|
||||
func AppCfg() *App {
|
||||
return app
|
||||
}
|
||||
|
||||
// LoadApp loads App configuration
|
||||
func LoadApp() {
|
||||
app.Host = os.Getenv("APP_HOST")
|
||||
app.Port, _ = strconv.Atoi(os.Getenv("APP_PORT"))
|
||||
app.Debug, _ = strconv.ParseBool(os.Getenv("APP_DEBUG"))
|
||||
app.Prefork, _ = strconv.ParseBool(os.Getenv("APP_PREFORK"))
|
||||
app.ProxyHeader = os.Getenv("APP_PROXY_HEADER")
|
||||
app.AllowOrigin = os.Getenv("APP_ALLOW_ORIGIN")
|
||||
app.SecretKey = os.Getenv("SECRET_KEY")
|
||||
app.LogLevel = os.Getenv("LOG_LEVEL")
|
||||
if app.LogLevel == "" {
|
||||
app.LogLevel = "INFO"
|
||||
}
|
||||
if app.Debug {
|
||||
app.LogLevel = "DEBUG"
|
||||
}
|
||||
}
|
18
internal/config/config.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// LoadAllConfigs set various configs
|
||||
func LoadAll(envFile string) {
|
||||
err := godotenv.Load(envFile)
|
||||
if err != nil {
|
||||
log.Fatalf("can't load .env file. error: %v", err)
|
||||
}
|
||||
|
||||
LoadApp()
|
||||
LoadDBCfg()
|
||||
}
|
31
internal/config/db.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// DB holds the DB configuration
|
||||
type DB struct {
|
||||
Host string
|
||||
Port int
|
||||
Name string
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
var db = &DB{}
|
||||
|
||||
// DBCfg returns the default DB configuration
|
||||
func DBCfg() *DB {
|
||||
return db
|
||||
}
|
||||
|
||||
// LoadDBCfg loads DB configuration
|
||||
func LoadDBCfg() {
|
||||
db.Host = os.Getenv("DB_HOST")
|
||||
db.Port, _ = strconv.Atoi(os.Getenv("DB_PORT"))
|
||||
db.User = os.Getenv("DB_USER")
|
||||
db.Password = os.Getenv("DB_PASSWORD")
|
||||
db.Name = os.Getenv("DB_NAME")
|
||||
}
|
50
internal/database/mysql.go
Normal file
|
@ -0,0 +1,50 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ditatompel/xmr-nodes/internal/config"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// DB holds the database
|
||||
type DB struct{ *sqlx.DB }
|
||||
|
||||
// database instance
|
||||
var defaultDB = &DB{}
|
||||
|
||||
// connect sets the db client of database using configuration
|
||||
func (db *DB) connect(cfg *config.DB) (err error) {
|
||||
dbURI := fmt.Sprintf("%s:%s@(%s:%d)/%s",
|
||||
cfg.User,
|
||||
cfg.Password,
|
||||
cfg.Host,
|
||||
cfg.Port,
|
||||
cfg.Name,
|
||||
)
|
||||
|
||||
db.DB, err = sqlx.Connect("mysql", dbURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Try to ping database.
|
||||
if err := db.Ping(); err != nil {
|
||||
defer db.Close() // close database connection
|
||||
return fmt.Errorf("can't sent ping to database, %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB returns db instance
|
||||
func GetDB() *DB {
|
||||
return defaultDB
|
||||
}
|
||||
|
||||
// ConnectDB sets the db client of database using default configuration
|
||||
func ConnectDB() error {
|
||||
return defaultDB.connect(config.DBCfg())
|
||||
}
|
169
internal/repo/admin.go
Normal file
|
@ -0,0 +1,169 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ditatompel/xmr-nodes/internal/database"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
)
|
||||
|
||||
type Admin struct {
|
||||
Id int `db:"id"`
|
||||
Username string `db:"username"`
|
||||
Password string `db:"password"`
|
||||
LastactiveTs int64 `db:"lastactive_ts"`
|
||||
CreatedTs int64 `db:"created_ts"`
|
||||
}
|
||||
|
||||
type AdminRepo struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
type AdminRepository interface {
|
||||
CreateAdmin(*Admin) (*Admin, error)
|
||||
Login(username string, password string) (*Admin, error)
|
||||
}
|
||||
|
||||
func NewAdminRepo(db *database.DB) AdminRepository {
|
||||
return &AdminRepo{db}
|
||||
}
|
||||
|
||||
func (repo *AdminRepo) CreateAdmin(admin *Admin) (*Admin, error) {
|
||||
if !validUsername(admin.Username) {
|
||||
return nil, errors.New("username is not valid, must be at least 4 characters long and contain only lowercase letters and numbers")
|
||||
}
|
||||
if !strongPassword(admin.Password) {
|
||||
return nil, errors.New("password is not strong enough, must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number and one special character")
|
||||
}
|
||||
hash, err := setPassword(admin.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
admin.Password = hash
|
||||
|
||||
admin.CreatedTs = time.Now().Unix()
|
||||
|
||||
if repo.isUsernameExists(admin.Username) {
|
||||
return nil, errors.New("username already exists")
|
||||
}
|
||||
|
||||
query := `INSERT INTO tbl_admin (username, password, created_ts) VALUES (?, ?, ?)`
|
||||
_, err = repo.db.Exec(query, admin.Username, admin.Password, admin.CreatedTs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
func (repo *AdminRepo) Login(username, password string) (*Admin, error) {
|
||||
query := `SELECT id, username, password FROM tbl_admin WHERE username = ? LIMIT 1`
|
||||
row, err := repo.db.Query(query, username)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return nil, err
|
||||
}
|
||||
defer row.Close()
|
||||
|
||||
admin := Admin{}
|
||||
if row.Next() {
|
||||
err = row.Scan(&admin.Id, &admin.Username, &admin.Password)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("Invalid username or password")
|
||||
}
|
||||
|
||||
match, err := checkPassword(admin.Password, password)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return nil, err
|
||||
}
|
||||
if !match {
|
||||
return nil, errors.New("Invalid username or password")
|
||||
}
|
||||
|
||||
update := `UPDATE tbl_admin SET lastactive_ts = ? WHERE id = ?`
|
||||
_, err = repo.db.Exec(update, time.Now().Unix(), admin.Id)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
func (repo *AdminRepo) isUsernameExists(username string) bool {
|
||||
query := `SELECT id FROM tbl_admin WHERE username = ? LIMIT 1`
|
||||
row, err := repo.db.Query(query, username)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer row.Close()
|
||||
return row.Next()
|
||||
}
|
||||
|
||||
func setPassword(password string) (string, error) {
|
||||
hash, err := argon2id.CreateHash(password, argon2id.DefaultParams)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func checkPassword(hash, password string) (bool, error) {
|
||||
match, err := argon2id.ComparePasswordAndHash(password, hash)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return match, nil
|
||||
}
|
||||
|
||||
func strongPassword(password string) bool {
|
||||
if len(password) < 8 {
|
||||
return false
|
||||
}
|
||||
if !strings.ContainsAny(password, "0123456789") {
|
||||
return false
|
||||
}
|
||||
if !strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||
return false
|
||||
}
|
||||
if !strings.ContainsAny(password, "abcdefghijklmnopqrstuvwxyz") {
|
||||
return false
|
||||
}
|
||||
if !strings.ContainsAny(password, "!@#$%^&*()_+|~-=`{}[]:;<>?,./") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// No special character and unicode for username
|
||||
func validUsername(username string) bool {
|
||||
if len(username) < 5 || len(username) > 20 {
|
||||
return false
|
||||
}
|
||||
|
||||
// reject witespace, tabs, newlines, and other special characters
|
||||
if strings.ContainsAny(username, " \t\n") {
|
||||
return false
|
||||
}
|
||||
// reject unicode
|
||||
if strings.ContainsAny(username, "^\x00-\x7F") {
|
||||
return false
|
||||
}
|
||||
// reject special characters
|
||||
if strings.ContainsAny(username, "!@#$%^&*()_+|~-=`{}[]:;<>?,./ ") { // note last blank space
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.ContainsAny(username, "abcdefghijklmnopqrstuvwxyz0123456789") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
112
internal/repo/cron.go
Normal file
|
@ -0,0 +1,112 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/ditatompel/xmr-nodes/internal/database"
|
||||
)
|
||||
|
||||
type CronRepository interface {
|
||||
RunCronProcess()
|
||||
Crons() ([]CronTask, error)
|
||||
}
|
||||
|
||||
type CronRepo struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
type CronTask struct {
|
||||
Id int `json:"id" db:"id"`
|
||||
Title string `json:"title" db:"title"`
|
||||
Slug string `json:"slug" db:"slug"`
|
||||
Description string `json:"description" db:"description"`
|
||||
RunEvery int `json:"run_every" db:"run_every"`
|
||||
LastRun int64 `json:"last_run" db:"last_run"`
|
||||
NextRun int64 `json:"next_run" db:"next_run"`
|
||||
RunTime float64 `json:"run_time" db:"run_time"`
|
||||
CronState int `json:"cron_state" db:"cron_state"`
|
||||
IsEnabled int `json:"is_enabled" db:"is_enabled"`
|
||||
}
|
||||
|
||||
var rerunTimeout = 300
|
||||
|
||||
func NewCron(db *database.DB) CronRepository {
|
||||
return &CronRepo{db}
|
||||
}
|
||||
|
||||
func (repo *CronRepo) RunCronProcess() {
|
||||
for {
|
||||
time.Sleep(60 * time.Second)
|
||||
fmt.Println("Running cron...")
|
||||
list, err := repo.queueList()
|
||||
if err != nil {
|
||||
fmt.Println("Error parsing to struct:", err)
|
||||
continue
|
||||
}
|
||||
for _, task := range list {
|
||||
startTime := time.Now()
|
||||
currentTs := startTime.Unix()
|
||||
delayedTask := currentTs - task.NextRun
|
||||
if task.CronState == 1 && delayedTask <= int64(rerunTimeout) {
|
||||
fmt.Println("SKIP STATE 1:", task.Slug)
|
||||
continue
|
||||
}
|
||||
|
||||
repo.preRunTask(task.Id, currentTs)
|
||||
|
||||
repo.execCron(task.Slug)
|
||||
|
||||
runTime := math.Ceil(time.Since(startTime).Seconds()*1000) / 1000
|
||||
fmt.Println("Runtime:", runTime)
|
||||
nextRun := currentTs + int64(task.RunEvery)
|
||||
|
||||
repo.postRunTask(task.Id, nextRun, runTime)
|
||||
}
|
||||
fmt.Println("Cron done!")
|
||||
}
|
||||
}
|
||||
|
||||
func (repo *CronRepo) Crons() ([]CronTask, error) {
|
||||
tasks := []CronTask{}
|
||||
query := `SELECT * FROM tbl_cron`
|
||||
err := repo.db.Select(&tasks, query)
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (repo *CronRepo) queueList() ([]CronTask, error) {
|
||||
tasks := []CronTask{}
|
||||
query := `SELECT id, run_every, last_run, slug, next_run, cron_state FROM tbl_cron
|
||||
WHERE is_enabled = ? AND next_run <= ?`
|
||||
err := repo.db.Select(&tasks, query, 1, time.Now().Unix())
|
||||
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
func (repo *CronRepo) preRunTask(id int, lastRunTs int64) {
|
||||
query := `UPDATE tbl_cron SET cron_state = ?, last_run = ? WHERE id = ?`
|
||||
row, err := repo.db.Query(query, 1, lastRunTs, id)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR PRERUN:", err)
|
||||
}
|
||||
defer row.Close()
|
||||
}
|
||||
|
||||
func (repo *CronRepo) postRunTask(id int, nextRun int64, runtime float64) {
|
||||
query := `UPDATE tbl_cron SET cron_state = ?, next_run = ?, run_time = ? WHERE id = ?`
|
||||
row, err := repo.db.Query(query, 0, nextRun, runtime, id)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR PRERUN:", err)
|
||||
}
|
||||
defer row.Close()
|
||||
}
|
||||
|
||||
func (repo *CronRepo) execCron(slug string) {
|
||||
switch slug {
|
||||
case "something":
|
||||
fmt.Println("Running task", slug)
|
||||
// do task
|
||||
break
|
||||
}
|
||||
}
|
104
internal/repo/prober.go
Normal file
|
@ -0,0 +1,104 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ditatompel/xmr-nodes/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ProberRepository interface {
|
||||
AddProber(name string) error
|
||||
Probers(q ProbersQueryParams) (Probers, error)
|
||||
}
|
||||
|
||||
type ProberRepo struct {
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
type Prober struct {
|
||||
Id int64 `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
ApiKey uuid.UUID `json:"api_key" db:"api_key"`
|
||||
LastSubmitTs int64 `json:"last_submit_ts" db:"last_submit_ts"`
|
||||
}
|
||||
|
||||
type ProbersQueryParams struct {
|
||||
Name string
|
||||
ApiKey string
|
||||
RowsPerPage int
|
||||
Page int
|
||||
}
|
||||
|
||||
type Probers struct {
|
||||
TotalRows int `json:"total_rows"`
|
||||
RowsPerPage int `json:"rows_per_page"`
|
||||
CurrentPage int `json:"current_page"`
|
||||
NextPage int `json:"next_page"`
|
||||
Items []*Prober `json:"items"`
|
||||
}
|
||||
|
||||
func NewProberRepo(db *database.DB) ProberRepository {
|
||||
return &ProberRepo{db}
|
||||
}
|
||||
|
||||
func (repo *ProberRepo) AddProber(name string) error {
|
||||
query := `INSERT INTO tbl_prober (name, api_key, last_submit_ts) VALUES (?, ?, ?)`
|
||||
_, err := repo.db.Exec(query, name, uuid.New(), 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *ProberRepo) Probers(q ProbersQueryParams) (Probers, error) {
|
||||
queryParams := []interface{}{}
|
||||
whereQueries := []string{}
|
||||
where := ""
|
||||
|
||||
if q.Name != "" {
|
||||
whereQueries = append(whereQueries, "name LIKE ?")
|
||||
queryParams = append(queryParams, "%"+q.Name+"%")
|
||||
}
|
||||
if q.ApiKey != "" {
|
||||
whereQueries = append(whereQueries, "api_key LIKE ?")
|
||||
queryParams = append(queryParams, "%"+q.ApiKey+"%")
|
||||
}
|
||||
|
||||
if len(whereQueries) > 0 {
|
||||
where = "WHERE " + strings.Join(whereQueries, " AND ")
|
||||
}
|
||||
|
||||
probers := Probers{}
|
||||
queryTotalRows := fmt.Sprintf("SELECT COUNT(id) AS total_rows FROM tbl_prober %s", where)
|
||||
|
||||
err := repo.db.QueryRow(queryTotalRows, queryParams...).Scan(&probers.TotalRows)
|
||||
if err != nil {
|
||||
return probers, err
|
||||
}
|
||||
queryParams = append(queryParams, q.RowsPerPage, (q.Page-1)*q.RowsPerPage)
|
||||
|
||||
query := fmt.Sprintf("SELECT id, name, api_key, last_submit_ts FROM tbl_prober %s ORDER BY id DESC LIMIT ? OFFSET ?", where)
|
||||
|
||||
row, err := repo.db.Query(query, queryParams...)
|
||||
if err != nil {
|
||||
return probers, err
|
||||
}
|
||||
defer row.Close()
|
||||
|
||||
probers.RowsPerPage = q.RowsPerPage
|
||||
probers.CurrentPage = q.Page
|
||||
probers.NextPage = q.Page + 1
|
||||
|
||||
for row.Next() {
|
||||
prober := Prober{}
|
||||
err = row.Scan(&prober.Id, &prober.Name, &prober.ApiKey, &prober.LastSubmitTs)
|
||||
if err != nil {
|
||||
return probers, err
|
||||
}
|
||||
probers.Items = append(probers.Items, &prober)
|
||||
}
|
||||
return probers, nil
|
||||
}
|
7
main.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
import "github.com/ditatompel/xmr-nodes/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
14
tools/mysql-dump.sh
Executable file
|
@ -0,0 +1,14 @@
|
|||
#!/bin/sh
|
||||
# Dump local dev database structure and required data from specific tables.
|
||||
# ------------------------------------------------------------------------------
|
||||
# shellcheck disable=SC2046 # Ignore SC2046: Quote this to prevent word splitting.
|
||||
|
||||
SD=$(dirname "$(readlink -f -- "$0")")
|
||||
cd "$SD" || exit 1 && cd ".." || exit 1
|
||||
|
||||
## Structure only dump
|
||||
mariadb-dump --no-data --skip-comments xmr_nodes | \
|
||||
sed 's/ AUTO_INCREMENT=[0-9]*//g' > \
|
||||
"./tools/resources/database/structure.sql"
|
||||
|
||||
# vim: set ts=4 sw=4 tw=0 et ft=sh:
|
63
tools/resources/database/structure.sql
Normal file
|
@ -0,0 +1,63 @@
|
|||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||
DROP TABLE IF EXISTS `tbl_admin`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `tbl_admin` (
|
||||
`id` bigint(30) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(200) NOT NULL,
|
||||
`password` varchar(200) NOT NULL,
|
||||
`lastactive_ts` int(11) unsigned NOT NULL DEFAULT 0,
|
||||
`created_ts` int(11) unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `username` (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `tbl_cron`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `tbl_cron` (
|
||||
`id` int(8) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(200) NOT NULL DEFAULT '',
|
||||
`slug` varchar(200) NOT NULL DEFAULT '',
|
||||
`description` varchar(200) DEFAULT NULL,
|
||||
`run_every` int(8) unsigned NOT NULL DEFAULT 60 COMMENT 'in seconds',
|
||||
`last_run` bigint(20) unsigned DEFAULT NULL,
|
||||
`next_run` bigint(20) unsigned DEFAULT NULL,
|
||||
`run_time` float(7,3) unsigned NOT NULL DEFAULT 0.000,
|
||||
`cron_state` tinyint(1) unsigned NOT NULL DEFAULT 0,
|
||||
`is_enabled` int(1) unsigned NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `tbl_prober`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `tbl_prober` (
|
||||
`id` int(9) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`api_key` varchar(36) NOT NULL,
|
||||
`last_submit_ts` int(11) unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `api_key` (`api_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|