feat: Add setup wizard and profile management components with styling

This commit is contained in:
Snider 2025-12-10 22:17:38 +00:00
parent 8f888a3749
commit 0d412e6faa
35 changed files with 2146 additions and 1436 deletions

View file

@ -43,7 +43,10 @@ var serveCmd = &cobra.Command{
// Use the global manager instance
mgr := getManager() // This ensures we get the manager initialized by initManager
service := mining.NewService(mgr, listenAddr, displayAddr, namespace) // Pass the global manager
service, err := mining.NewService(mgr, listenAddr, displayAddr, namespace) // Pass the global manager
if err != nil {
return fmt.Errorf("failed to create new service: %w", err)
}
// Start the server in a goroutine
go func() {

View file

@ -37,14 +37,14 @@ const docTemplate = `{
},
"/info": {
"get": {
"description": "Retrieves the last cached installation details for all miners, along with system information.",
"description": "Retrieves live installation details for all miners, along with system information.",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get cached miner installation information",
"summary": "Get live miner installation information",
"responses": {
"200": {
"description": "OK",
@ -203,47 +203,6 @@ const docTemplate = `{
}
}
},
"/miners/{miner_type}": {
"post": {
"description": "Start a new miner with the given configuration",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"miners"
],
"summary": "Start a new miner",
"parameters": [
{
"type": "string",
"description": "Miner Type",
"name": "miner_type",
"in": "path",
"required": true
},
{
"description": "Miner Configuration",
"name": "config",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mining.Config"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mining.XMRigMiner"
}
}
}
}
},
"/miners/{miner_type}/install": {
"post": {
"description": "Install a new miner or update an existing one.",
@ -308,6 +267,188 @@ const docTemplate = `{
}
}
},
"/profiles": {
"get": {
"description": "Get a list of all saved mining profiles",
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "List all mining profiles",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
}
}
},
"post": {
"description": "Create and save a new mining profile",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Create a new mining profile",
"parameters": [
{
"description": "Mining Profile",
"name": "profile",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
}
}
},
"/profiles/{id}": {
"get": {
"description": "Get a mining profile by its ID",
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Get a specific mining profile",
"parameters": [
{
"type": "string",
"description": "Profile ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
}
},
"put": {
"description": "Update an existing mining profile",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Update a mining profile",
"parameters": [
{
"type": "string",
"description": "Profile ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Updated Mining Profile",
"name": "profile",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
}
},
"delete": {
"description": "Delete a mining profile by its ID",
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Delete a mining profile",
"parameters": [
{
"type": "string",
"description": "Profile ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/profiles/{id}/start": {
"post": {
"description": "Start a new miner with the configuration from a saved profile",
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Start a new miner using a profile",
"parameters": [
{
"type": "string",
"description": "Profile ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mining.XMRigMiner"
}
}
}
}
},
"/update": {
"post": {
"description": "Checks if any installed miners have a new version available for download.",
@ -358,200 +499,6 @@ const docTemplate = `{
}
}
},
"mining.Config": {
"type": "object",
"properties": {
"algo": {
"type": "string"
},
"apiId": {
"type": "string"
},
"apiWorkerId": {
"type": "string"
},
"argon2Impl": {
"type": "string"
},
"asm": {
"type": "string"
},
"av": {
"type": "integer"
},
"background": {
"type": "boolean"
},
"bench": {
"type": "string"
},
"coin": {
"type": "string"
},
"cpuAffinity": {
"type": "string"
},
"cpuMaxThreadsHint": {
"type": "integer"
},
"cpuMemoryPool": {
"type": "integer"
},
"cpuNoYield": {
"type": "boolean"
},
"cpuPriority": {
"type": "integer"
},
"donateLevel": {
"type": "integer"
},
"donateOverProxy": {
"type": "boolean"
},
"hash": {
"type": "string"
},
"healthPrintTime": {
"type": "integer"
},
"httpAccessToken": {
"type": "string"
},
"httpHost": {
"type": "string"
},
"httpNoRestricted": {
"type": "boolean"
},
"httpPort": {
"type": "integer"
},
"hugePages": {
"type": "boolean"
},
"hugePagesJIT": {
"type": "boolean"
},
"hugepageSize": {
"type": "integer"
},
"keepalive": {
"type": "boolean"
},
"logFile": {
"type": "string"
},
"logOutput": {
"type": "boolean"
},
"miner": {
"type": "string"
},
"nicehash": {
"type": "boolean"
},
"noColor": {
"type": "boolean"
},
"noCpu": {
"type": "boolean"
},
"noDMI": {
"type": "boolean"
},
"noTitle": {
"type": "boolean"
},
"password": {
"type": "string"
},
"pauseOnActive": {
"type": "integer"
},
"pauseOnBattery": {
"type": "boolean"
},
"pool": {
"type": "string"
},
"printTime": {
"type": "integer"
},
"proxy": {
"type": "string"
},
"randomX1GBPages": {
"type": "boolean"
},
"randomXCacheQoS": {
"type": "boolean"
},
"randomXInit": {
"type": "integer"
},
"randomXMode": {
"type": "string"
},
"randomXNoNuma": {
"type": "boolean"
},
"randomXNoRdmsr": {
"type": "boolean"
},
"randomXWrmsr": {
"type": "string"
},
"retries": {
"type": "integer"
},
"retryPause": {
"type": "integer"
},
"rigId": {
"type": "string"
},
"seed": {
"type": "string"
},
"stress": {
"type": "boolean"
},
"submit": {
"type": "boolean"
},
"syslog": {
"type": "boolean"
},
"threads": {
"type": "integer"
},
"title": {
"type": "string"
},
"tls": {
"type": "boolean"
},
"tlsFingerprint": {
"type": "string"
},
"userAgent": {
"type": "string"
},
"userPass": {
"type": "string"
},
"verbose": {
"type": "boolean"
},
"verify": {
"type": "string"
},
"wallet": {
"type": "string"
}
}
},
"mining.HashratePoint": {
"type": "object",
"properties": {
@ -584,6 +531,25 @@ const docTemplate = `{
}
}
},
"mining.MiningProfile": {
"type": "object",
"properties": {
"config": {
"description": "The raw JSON config for the specific miner",
"type": "object"
},
"id": {
"type": "string"
},
"minerType": {
"description": "e.g., \"xmrig\", \"ttminer\"",
"type": "string"
},
"name": {
"type": "string"
}
}
},
"mining.PerformanceMetrics": {
"type": "object",
"properties": {

View file

@ -31,14 +31,14 @@
},
"/info": {
"get": {
"description": "Retrieves the last cached installation details for all miners, along with system information.",
"description": "Retrieves live installation details for all miners, along with system information.",
"produces": [
"application/json"
],
"tags": [
"system"
],
"summary": "Get cached miner installation information",
"summary": "Get live miner installation information",
"responses": {
"200": {
"description": "OK",
@ -197,47 +197,6 @@
}
}
},
"/miners/{miner_type}": {
"post": {
"description": "Start a new miner with the given configuration",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"miners"
],
"summary": "Start a new miner",
"parameters": [
{
"type": "string",
"description": "Miner Type",
"name": "miner_type",
"in": "path",
"required": true
},
{
"description": "Miner Configuration",
"name": "config",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mining.Config"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mining.XMRigMiner"
}
}
}
}
},
"/miners/{miner_type}/install": {
"post": {
"description": "Install a new miner or update an existing one.",
@ -302,6 +261,188 @@
}
}
},
"/profiles": {
"get": {
"description": "Get a list of all saved mining profiles",
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "List all mining profiles",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
}
}
},
"post": {
"description": "Create and save a new mining profile",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Create a new mining profile",
"parameters": [
{
"description": "Mining Profile",
"name": "profile",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
}
}
},
"/profiles/{id}": {
"get": {
"description": "Get a mining profile by its ID",
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Get a specific mining profile",
"parameters": [
{
"type": "string",
"description": "Profile ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
}
},
"put": {
"description": "Update an existing mining profile",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Update a mining profile",
"parameters": [
{
"type": "string",
"description": "Profile ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Updated Mining Profile",
"name": "profile",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mining.MiningProfile"
}
}
}
},
"delete": {
"description": "Delete a mining profile by its ID",
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Delete a mining profile",
"parameters": [
{
"type": "string",
"description": "Profile ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/profiles/{id}/start": {
"post": {
"description": "Start a new miner with the configuration from a saved profile",
"produces": [
"application/json"
],
"tags": [
"profiles"
],
"summary": "Start a new miner using a profile",
"parameters": [
{
"type": "string",
"description": "Profile ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/mining.XMRigMiner"
}
}
}
}
},
"/update": {
"post": {
"description": "Checks if any installed miners have a new version available for download.",
@ -352,200 +493,6 @@
}
}
},
"mining.Config": {
"type": "object",
"properties": {
"algo": {
"type": "string"
},
"apiId": {
"type": "string"
},
"apiWorkerId": {
"type": "string"
},
"argon2Impl": {
"type": "string"
},
"asm": {
"type": "string"
},
"av": {
"type": "integer"
},
"background": {
"type": "boolean"
},
"bench": {
"type": "string"
},
"coin": {
"type": "string"
},
"cpuAffinity": {
"type": "string"
},
"cpuMaxThreadsHint": {
"type": "integer"
},
"cpuMemoryPool": {
"type": "integer"
},
"cpuNoYield": {
"type": "boolean"
},
"cpuPriority": {
"type": "integer"
},
"donateLevel": {
"type": "integer"
},
"donateOverProxy": {
"type": "boolean"
},
"hash": {
"type": "string"
},
"healthPrintTime": {
"type": "integer"
},
"httpAccessToken": {
"type": "string"
},
"httpHost": {
"type": "string"
},
"httpNoRestricted": {
"type": "boolean"
},
"httpPort": {
"type": "integer"
},
"hugePages": {
"type": "boolean"
},
"hugePagesJIT": {
"type": "boolean"
},
"hugepageSize": {
"type": "integer"
},
"keepalive": {
"type": "boolean"
},
"logFile": {
"type": "string"
},
"logOutput": {
"type": "boolean"
},
"miner": {
"type": "string"
},
"nicehash": {
"type": "boolean"
},
"noColor": {
"type": "boolean"
},
"noCpu": {
"type": "boolean"
},
"noDMI": {
"type": "boolean"
},
"noTitle": {
"type": "boolean"
},
"password": {
"type": "string"
},
"pauseOnActive": {
"type": "integer"
},
"pauseOnBattery": {
"type": "boolean"
},
"pool": {
"type": "string"
},
"printTime": {
"type": "integer"
},
"proxy": {
"type": "string"
},
"randomX1GBPages": {
"type": "boolean"
},
"randomXCacheQoS": {
"type": "boolean"
},
"randomXInit": {
"type": "integer"
},
"randomXMode": {
"type": "string"
},
"randomXNoNuma": {
"type": "boolean"
},
"randomXNoRdmsr": {
"type": "boolean"
},
"randomXWrmsr": {
"type": "string"
},
"retries": {
"type": "integer"
},
"retryPause": {
"type": "integer"
},
"rigId": {
"type": "string"
},
"seed": {
"type": "string"
},
"stress": {
"type": "boolean"
},
"submit": {
"type": "boolean"
},
"syslog": {
"type": "boolean"
},
"threads": {
"type": "integer"
},
"title": {
"type": "string"
},
"tls": {
"type": "boolean"
},
"tlsFingerprint": {
"type": "string"
},
"userAgent": {
"type": "string"
},
"userPass": {
"type": "string"
},
"verbose": {
"type": "boolean"
},
"verify": {
"type": "string"
},
"wallet": {
"type": "string"
}
}
},
"mining.HashratePoint": {
"type": "object",
"properties": {
@ -578,6 +525,25 @@
}
}
},
"mining.MiningProfile": {
"type": "object",
"properties": {
"config": {
"description": "The raw JSON config for the specific miner",
"type": "object"
},
"id": {
"type": "string"
},
"minerType": {
"description": "e.g., \"xmrig\", \"ttminer\"",
"type": "string"
},
"name": {
"type": "string"
}
}
},
"mining.PerformanceMetrics": {
"type": "object",
"properties": {

View file

@ -16,135 +16,6 @@ definitions:
name:
type: string
type: object
mining.Config:
properties:
algo:
type: string
apiId:
type: string
apiWorkerId:
type: string
argon2Impl:
type: string
asm:
type: string
av:
type: integer
background:
type: boolean
bench:
type: string
coin:
type: string
cpuAffinity:
type: string
cpuMaxThreadsHint:
type: integer
cpuMemoryPool:
type: integer
cpuNoYield:
type: boolean
cpuPriority:
type: integer
donateLevel:
type: integer
donateOverProxy:
type: boolean
hash:
type: string
healthPrintTime:
type: integer
httpAccessToken:
type: string
httpHost:
type: string
httpNoRestricted:
type: boolean
httpPort:
type: integer
hugePages:
type: boolean
hugePagesJIT:
type: boolean
hugepageSize:
type: integer
keepalive:
type: boolean
logFile:
type: string
logOutput:
type: boolean
miner:
type: string
nicehash:
type: boolean
noColor:
type: boolean
noCpu:
type: boolean
noDMI:
type: boolean
noTitle:
type: boolean
password:
type: string
pauseOnActive:
type: integer
pauseOnBattery:
type: boolean
pool:
type: string
printTime:
type: integer
proxy:
type: string
randomX1GBPages:
type: boolean
randomXCacheQoS:
type: boolean
randomXInit:
type: integer
randomXMode:
type: string
randomXNoNuma:
type: boolean
randomXNoRdmsr:
type: boolean
randomXWrmsr:
type: string
retries:
type: integer
retryPause:
type: integer
rigId:
type: string
seed:
type: string
stress:
type: boolean
submit:
type: boolean
syslog:
type: boolean
threads:
type: integer
title:
type: string
tls:
type: boolean
tlsFingerprint:
type: string
userAgent:
type: string
userPass:
type: string
verbose:
type: boolean
verify:
type: string
wallet:
type: string
type: object
mining.HashratePoint:
properties:
hashrate:
@ -166,6 +37,19 @@ definitions:
version:
type: string
type: object
mining.MiningProfile:
properties:
config:
description: The raw JSON config for the specific miner
type: object
id:
type: string
minerType:
description: e.g., "xmrig", "ttminer"
type: string
name:
type: string
type: object
mining.PerformanceMetrics:
properties:
algorithm:
@ -253,8 +137,8 @@ paths:
- system
/info:
get:
description: Retrieves the last cached installation details for all miners,
along with system information.
description: Retrieves live installation details for all miners, along with
system information.
produces:
- application/json
responses:
@ -268,7 +152,7 @@ paths:
additionalProperties:
type: string
type: object
summary: Get cached miner installation information
summary: Get live miner installation information
tags:
- system
/miners:
@ -347,33 +231,6 @@ paths:
summary: Get miner stats
tags:
- miners
/miners/{miner_type}:
post:
consumes:
- application/json
description: Start a new miner with the given configuration
parameters:
- description: Miner Type
in: path
name: miner_type
required: true
type: string
- description: Miner Configuration
in: body
name: config
required: true
schema:
$ref: '#/definitions/mining.Config'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/mining.XMRigMiner'
summary: Start a new miner
tags:
- miners
/miners/{miner_type}/install:
post:
description: Install a new miner or update an existing one.
@ -431,6 +288,126 @@ paths:
summary: List all available miners
tags:
- miners
/profiles:
get:
description: Get a list of all saved mining profiles
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/mining.MiningProfile'
type: array
summary: List all mining profiles
tags:
- profiles
post:
consumes:
- application/json
description: Create and save a new mining profile
parameters:
- description: Mining Profile
in: body
name: profile
required: true
schema:
$ref: '#/definitions/mining.MiningProfile'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/mining.MiningProfile'
summary: Create a new mining profile
tags:
- profiles
/profiles/{id}:
delete:
description: Delete a mining profile by its ID
parameters:
- description: Profile ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
summary: Delete a mining profile
tags:
- profiles
get:
description: Get a mining profile by its ID
parameters:
- description: Profile ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/mining.MiningProfile'
summary: Get a specific mining profile
tags:
- profiles
put:
consumes:
- application/json
description: Update an existing mining profile
parameters:
- description: Profile ID
in: path
name: id
required: true
type: string
- description: Updated Mining Profile
in: body
name: profile
required: true
schema:
$ref: '#/definitions/mining.MiningProfile'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/mining.MiningProfile'
summary: Update a mining profile
tags:
- profiles
/profiles/{id}/start:
post:
description: Start a new miner with the configuration from a saved profile
parameters:
- description: Profile ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/mining.XMRigMiner'
summary: Start a new miner using a profile
tags:
- profiles
/update:
post:
description: Checks if any installed miners have a new version available for

1
go.mod
View file

@ -7,6 +7,7 @@ require (
github.com/adrg/xdg v0.5.3
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/shirou/gopsutil/v4 v4.25.10
github.com/spf13/cobra v1.8.1
github.com/swaggo/files v1.0.1

2
go.sum
View file

@ -68,6 +68,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=

View file

@ -1,12 +1,36 @@
package mining
// MiningProfile represents a saved configuration for a specific mining setup.
// This allows users to define and switch between different miners, pools,
// and wallets without re-entering information.
import (
"errors"
)
// RawConfig is a raw encoded JSON value.
// It implements Marshaler and Unmarshaler and can be used to delay JSON decoding or precompute a JSON encoding.
// We define it as []byte (like json.RawMessage) to avoid swagger parsing issues with the json package.
type RawConfig []byte
// MiningProfile represents a saved configuration for running a specific miner.
// It decouples the UI from the underlying miner's specific config structure.
type MiningProfile struct {
Name string `json:"name"` // A user-defined name for the profile, e.g., "My XMR Rig"
Pool string `json:"pool"` // The mining pool address
Wallet string `json:"wallet"` // The wallet address
Miner string `json:"miner"` // The type of miner, e.g., "xmrig"
// This can be expanded later to include the full *Config for advanced options
ID string `json:"id"`
Name string `json:"name"`
MinerType string `json:"minerType"` // e.g., "xmrig", "ttminer"
Config RawConfig `json:"config" swaggertype:"object"` // The raw JSON config for the specific miner
}
// MarshalJSON returns m as the JSON encoding of m.
func (m RawConfig) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("null"), nil
}
return m, nil
}
// UnmarshalJSON sets *m to a copy of data.
func (m *RawConfig) UnmarshalJSON(data []byte) error {
if m == nil {
return errors.New("RawConfig: UnmarshalJSON on nil pointer")
}
*m = append((*m)[0:0], data...)
return nil
}

View file

@ -0,0 +1,147 @@
package mining
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/adrg/xdg"
"github.com/google/uuid"
)
const profileConfigFileName = "mining_profiles.json"
// ProfileManager handles CRUD operations for MiningProfiles.
type ProfileManager struct {
mu sync.RWMutex
profiles map[string]*MiningProfile
configPath string
}
// NewProfileManager creates and initializes a new ProfileManager.
func NewProfileManager() (*ProfileManager, error) {
configPath, err := xdg.ConfigFile(filepath.Join("lethean-desktop", profileConfigFileName))
if err != nil {
return nil, fmt.Errorf("could not resolve config path: %w", err)
}
pm := &ProfileManager{
profiles: make(map[string]*MiningProfile),
configPath: configPath,
}
if err := pm.loadProfiles(); err != nil {
// If the file doesn't exist, that's fine, but any other error is a problem.
if !os.IsNotExist(err) {
return nil, fmt.Errorf("could not load profiles: %w", err)
}
}
return pm, nil
}
// loadProfiles reads the profiles from the JSON file into memory.
func (pm *ProfileManager) loadProfiles() error {
pm.mu.Lock()
defer pm.mu.Unlock()
data, err := os.ReadFile(pm.configPath)
if err != nil {
return err
}
var profiles []*MiningProfile
if err := json.Unmarshal(data, &profiles); err != nil {
return err
}
pm.profiles = make(map[string]*MiningProfile)
for _, p := range profiles {
pm.profiles[p.ID] = p
}
return nil
}
// saveProfiles writes the current profiles from memory to the JSON file.
func (pm *ProfileManager) saveProfiles() error {
pm.mu.RLock()
defer pm.mu.RUnlock()
profileList := make([]*MiningProfile, 0, len(pm.profiles))
for _, p := range pm.profiles {
profileList = append(profileList, p)
}
data, err := json.MarshalIndent(profileList, "", " ")
if err != nil {
return err
}
return os.WriteFile(pm.configPath, data, 0644)
}
// CreateProfile adds a new profile and saves it.
func (pm *ProfileManager) CreateProfile(profile *MiningProfile) (*MiningProfile, error) {
pm.mu.Lock()
defer pm.mu.Unlock()
profile.ID = uuid.New().String()
pm.profiles[profile.ID] = profile
if err := pm.saveProfiles(); err != nil {
// Rollback
delete(pm.profiles, profile.ID)
return nil, err
}
return profile, nil
}
// GetProfile retrieves a profile by its ID.
func (pm *ProfileManager) GetProfile(id string) (*MiningProfile, bool) {
pm.mu.RLock()
defer pm.mu.RUnlock()
profile, exists := pm.profiles[id]
return profile, exists
}
// GetAllProfiles returns a list of all profiles.
func (pm *ProfileManager) GetAllProfiles() []*MiningProfile {
pm.mu.RLock()
defer pm.mu.RUnlock()
profileList := make([]*MiningProfile, 0, len(pm.profiles))
for _, p := range pm.profiles {
profileList = append(profileList, p)
}
return profileList
}
// UpdateProfile modifies an existing profile.
func (pm *ProfileManager) UpdateProfile(profile *MiningProfile) error {
pm.mu.Lock()
defer pm.mu.Unlock()
if _, exists := pm.profiles[profile.ID]; !exists {
return fmt.Errorf("profile with ID %s not found", profile.ID)
}
pm.profiles[profile.ID] = profile
return pm.saveProfiles()
}
// DeleteProfile removes a profile by its ID.
func (pm *ProfileManager) DeleteProfile(id string) error {
pm.mu.Lock()
defer pm.mu.Unlock()
if _, exists := pm.profiles[id]; !exists {
return fmt.Errorf("profile with ID %s not found", id)
}
delete(pm.profiles, id)
return pm.saveProfiles()
}

View file

@ -27,6 +27,7 @@ import (
// Service encapsulates the gin-gonic router and the mining manager.
type Service struct {
Manager ManagerInterface
ProfileManager *ProfileManager
Router *gin.Engine
Server *http.Server
DisplayAddr string
@ -36,7 +37,7 @@ type Service struct {
}
// NewService creates a new mining service
func NewService(manager ManagerInterface, listenAddr string, displayAddr string, swaggerNamespace string) *Service {
func NewService(manager ManagerInterface, listenAddr string, displayAddr string, swaggerNamespace string) (*Service, error) {
apiBasePath := "/" + strings.Trim(swaggerNamespace, "/")
swaggerUIPath := apiBasePath + "/swagger"
@ -47,8 +48,14 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string,
instanceName := "swagger_" + strings.ReplaceAll(strings.Trim(swaggerNamespace, "/"), "/", "_")
swag.Register(instanceName, docs.SwaggerInfo)
profileManager, err := NewProfileManager()
if err != nil {
return nil, fmt.Errorf("failed to initialize profile manager: %w", err)
}
return &Service{
Manager: manager,
Manager: manager,
ProfileManager: profileManager,
Server: &http.Server{
Addr: listenAddr,
},
@ -56,7 +63,7 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string,
SwaggerInstanceName: instanceName,
APIBasePath: apiBasePath,
SwaggerUIPath: swaggerUIPath,
}
}, nil
}
func (s *Service) ServiceStartup(ctx context.Context) error {
@ -95,13 +102,22 @@ func (s *Service) setupRoutes() {
{
minersGroup.GET("", s.handleListMiners)
minersGroup.GET("/available", s.handleListAvailableMiners)
minersGroup.POST("/:miner_name", s.handleStartMiner)
minersGroup.POST("/:miner_name/install", s.handleInstallMiner)
minersGroup.DELETE("/:miner_name/uninstall", s.handleUninstallMiner)
minersGroup.DELETE("/:miner_name", s.handleStopMiner)
minersGroup.GET("/:miner_name/stats", s.handleGetMinerStats)
minersGroup.GET("/:miner_name/hashrate-history", s.handleGetMinerHashrateHistory)
}
profilesGroup := apiGroup.Group("/profiles")
{
profilesGroup.GET("", s.handleListProfiles)
profilesGroup.POST("", s.handleCreateProfile)
profilesGroup.GET("/:id", s.handleGetProfile)
profilesGroup.PUT("/:id", s.handleUpdateProfile)
profilesGroup.DELETE("/:id", s.handleDeleteProfile)
profilesGroup.POST("/:id/start", s.handleStartMinerWithProfile)
}
}
s.Router.StaticFile("/component/mining-dashboard.js", "./ui/dist/ui/mbe-mining-dashboard.js")
@ -111,37 +127,19 @@ func (s *Service) setupRoutes() {
}
// handleGetInfo godoc
// @Summary Get cached miner installation information
// @Description Retrieves the last cached installation details for all miners, along with system information.
// @Summary Get live miner installation information
// @Description Retrieves live installation details for all miners, along with system information.
// @Tags system
// @Produce json
// @Success 200 {object} SystemInfo
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /info [get]
func (s *Service) handleGetInfo(c *gin.Context) {
configDir, err := xdg.ConfigFile("lethean-desktop/miners")
systemInfo, err := s.updateInstallationCache()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get config directory"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get system info", "details": err.Error()})
return
}
configPath := filepath.Join(configDir, "config.json")
cacheBytes, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cache file not found, run setup"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read cache file"})
return
}
var systemInfo SystemInfo
if err := json.Unmarshal(cacheBytes, &systemInfo); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not parse cache file"})
return
}
c.JSON(http.StatusOK, systemInfo)
}
@ -343,24 +341,29 @@ func (s *Service) handleInstallMiner(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "installed", "version": details.Version, "path": details.Path})
}
// handleStartMiner godoc
// @Summary Start a new miner
// @Description Start a new miner with the given configuration
// @Tags miners
// @Accept json
// handleStartMinerWithProfile godoc
// @Summary Start a new miner using a profile
// @Description Start a new miner with the configuration from a saved profile
// @Tags profiles
// @Produce json
// @Param miner_type path string true "Miner Type"
// @Param config body Config true "Miner Configuration"
// @Param id path string true "Profile ID"
// @Success 200 {object} XMRigMiner
// @Router /miners/{miner_type} [post]
func (s *Service) handleStartMiner(c *gin.Context) {
minerType := c.Param("miner_name")
var config Config
if err := c.ShouldBindJSON(&config); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// @Router /profiles/{id}/start [post]
func (s *Service) handleStartMinerWithProfile(c *gin.Context) {
profileID := c.Param("id")
profile, exists := s.ProfileManager.GetProfile(profileID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"})
return
}
miner, err := s.Manager.StartMiner(minerType, &config)
var config Config
if err := json.Unmarshal(profile.Config, &config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse profile config", "details": err.Error()})
return
}
miner, err := s.Manager.StartMiner(profile.MinerType, &config)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@ -425,3 +428,101 @@ func (s *Service) handleGetMinerHashrateHistory(c *gin.Context) {
}
c.JSON(http.StatusOK, history)
}
// handleListProfiles godoc
// @Summary List all mining profiles
// @Description Get a list of all saved mining profiles
// @Tags profiles
// @Produce json
// @Success 200 {array} MiningProfile
// @Router /profiles [get]
func (s *Service) handleListProfiles(c *gin.Context) {
profiles := s.ProfileManager.GetAllProfiles()
c.JSON(http.StatusOK, profiles)
}
// handleCreateProfile godoc
// @Summary Create a new mining profile
// @Description Create and save a new mining profile
// @Tags profiles
// @Accept json
// @Produce json
// @Param profile body MiningProfile true "Mining Profile"
// @Success 201 {object} MiningProfile
// @Router /profiles [post]
func (s *Service) handleCreateProfile(c *gin.Context) {
var profile MiningProfile
if err := c.ShouldBindJSON(&profile); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
createdProfile, err := s.ProfileManager.CreateProfile(&profile)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create profile", "details": err.Error()})
return
}
c.JSON(http.StatusCreated, createdProfile)
}
// handleGetProfile godoc
// @Summary Get a specific mining profile
// @Description Get a mining profile by its ID
// @Tags profiles
// @Produce json
// @Param id path string true "Profile ID"
// @Success 200 {object} MiningProfile
// @Router /profiles/{id} [get]
func (s *Service) handleGetProfile(c *gin.Context) {
profileID := c.Param("id")
profile, exists := s.ProfileManager.GetProfile(profileID)
if !exists {
c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"})
return
}
c.JSON(http.StatusOK, profile)
}
// handleUpdateProfile godoc
// @Summary Update a mining profile
// @Description Update an existing mining profile
// @Tags profiles
// @Accept json
// @Produce json
// @Param id path string true "Profile ID"
// @Param profile body MiningProfile true "Updated Mining Profile"
// @Success 200 {object} MiningProfile
// @Router /profiles/{id} [put]
func (s *Service) handleUpdateProfile(c *gin.Context) {
profileID := c.Param("id")
var profile MiningProfile
if err := c.ShouldBindJSON(&profile); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
profile.ID = profileID
if err := s.ProfileManager.UpdateProfile(&profile); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile", "details": err.Error()})
return
}
c.JSON(http.StatusOK, profile)
}
// handleDeleteProfile godoc
// @Summary Delete a mining profile
// @Description Delete a mining profile by its ID
// @Tags profiles
// @Produce json
// @Param id path string true "Profile ID"
// @Success 200 {object} map[string]string
// @Router /profiles/{id} [delete]
func (s *Service) handleDeleteProfile(c *gin.Context) {
profileID := c.Param("id")
if err := s.ProfileManager.DeleteProfile(profileID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete profile", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "profile deleted"})
}

View file

@ -49,7 +49,7 @@ func (m *XMRigMiner) Start(config *Config) error {
}
}
args := []string{"-c", m.ConfigPath}
args := []string{"-c", "\"" + m.ConfigPath + "\""}
if m.API != nil && m.API.Enabled {
args = append(args, "--http-host", m.API.ListenHost, "--http-port", fmt.Sprintf("%d", m.API.ListenPort))

View file

@ -47,6 +47,7 @@
font-family: monospace;
border-radius: 0.25rem;
border: 1px solid #e0e0e0;
font-size: 0.749rem; /* Smaller font for easier selection */
}
.path-list li {

View file

@ -1,97 +1,63 @@
<!-- Setup Wizard View -->
@if (needsSetup) {
<div class="setup-wizard">
<p>To begin, please install a miner from the list below.</p>
<h4>Available Miners</h4>
<div class="miner-list">
@for (miner of manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
<div class="admin-panel">
@if (error()) {
<wa-card class="card-error">
<div slot="header">
<wa-icon name="exclamation-triangle" style="font-size: 1.5rem;"></wa-icon>
An Error Occurred
</div>
<p>{{ error() }}</p>
</wa-card>
}
<h4>Manage Miners</h4>
<div class="miner-list">
@for (miner of state().manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
@if (miner.is_installed) {
<wa-button
variant="danger"
size="small"
[disabled]="actionInProgress() === 'uninstall-' + miner.name"
(click)="uninstallMiner(miner.name)">
@if (actionInProgress() === 'uninstall-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="trash" slot="prefix"></wa-icon>
Uninstall
}
</wa-button>
} @else {
<wa-button
variant="success"
size="small"
[disabled]="actionInProgress === 'install-' + miner.name"
[disabled]="actionInProgress() === 'install-' + miner.name"
(click)="installMiner(miner.name)">
@if (actionInProgress === 'install-' + miner.name) {
@if (actionInProgress() === 'install-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="download" slot="prefix"></wa-icon>
Install
}
</wa-button>
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
</div>
}
<!-- Standard Admin Panel View -->
@if (!needsSetup) {
<div class="admin-panel">
@if (error) {
<wa-card class="card-error">
<div slot="header">
<wa-icon name="exclamation-triangle" style="font-size: 1.5rem;"></wa-icon>
An Error Occurred
</div>
<p>{{ error }}</p>
</wa-card>
}
<h4>Manage Miners</h4>
<div class="miner-list">
@for (miner of manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
@if (miner.is_installed) {
<wa-button
variant="danger"
size="small"
[disabled]="actionInProgress === 'uninstall-' + miner.name"
(click)="uninstallMiner(miner.name)">
@if (actionInProgress === 'uninstall-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="trash" slot="prefix"></wa-icon>
Uninstall
}
</wa-button>
} @else {
<wa-button
variant="success"
size="small"
[disabled]="actionInProgress === 'install-' + miner.name"
(click)="installMiner(miner.name)">
@if (actionInProgress === 'install-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="download" slot="prefix"></wa-icon>
Install
}
</wa-button>
}
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
<h4 class="section-title">Antivirus Whitelist Paths</h4>
<div class="path-list">
<p>To prevent antivirus software from interfering, please add the following paths to your exclusion list:</p>
<ul>
@for (path of whitelistPaths; track path) {
<li><code>{{ path }}</code></li>
} @empty {
<li>No paths to display. Install a miner to see required paths.</li>
}
</ul>
</div>
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
}
<h4 class="section-title">Antivirus Whitelist Paths</h4>
<div class="path-list">
<p>To prevent antivirus software from interfering, please add the following paths to your exclusion list:</p>
<ul>
@for (path of whitelistPaths(); track path) {
<li><code>{{ path }}</code></li>
} @empty {
<li>No paths to display. Install a miner to see required paths.</li>
}
</ul>
</div>
</div>

View file

@ -1,130 +1,72 @@
import { Component, OnInit, Input, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { HttpClient, HttpClientModule, HttpErrorResponse } from '@angular/common/http';
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { of, forkJoin } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { HttpErrorResponse } from '@angular/common/http';
import { MinerService } from './miner.service';
// Define interfaces for our data structures
interface InstallationDetails {
is_installed: boolean;
version: string;
path: string;
miner_binary: string;
config_path?: string;
type?: string;
}
interface AvailableMiner {
name: string;
description: string;
}
// Import Web Awesome components
import "@awesome.me/webawesome/dist/webawesome.js";
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/card/card.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@Component({
selector: 'snider-mining-admin',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, HttpClientModule],
imports: [CommonModule],
templateUrl: './admin.component.html',
styleUrls: ['./admin.component.css'],
})
export class MiningAdminComponent implements OnInit {
@Input() needsSetup: boolean = false; // Input to trigger setup mode
export class MiningAdminComponent {
minerService = inject(MinerService);
state = this.minerService.state;
actionInProgress = signal<string | null>(null);
error = signal<string | null>(null);
apiBaseUrl: string = 'http://localhost:9090/api/v1/mining';
error: string | null = null;
actionInProgress: string | null = null;
manageableMiners: any[] = [];
whitelistPaths: string[] = [];
constructor(private http: HttpClient) {}
ngOnInit(): void {
this.getAdminData();
}
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
this.actionInProgress = null;
if (err.error && err.error.error) {
this.error = `${defaultMessage}: ${err.error.error}`;
} else {
this.error = `${defaultMessage}. Please check the console for details.`;
}
}
getAdminData(): void {
this.error = null;
forkJoin({
available: this.http.get<AvailableMiner[]>(`${this.apiBaseUrl}/miners/available`),
info: this.http.get<any>(`${this.apiBaseUrl}/info`).pipe(catchError(() => of({}))) // Gracefully handle info error
}).pipe(
map(({ available, info }) => {
const installedMap = new Map<string, InstallationDetails>(
(info.installed_miners_info || []).map((m: InstallationDetails) => [this.getMinerType(m), m])
);
this.manageableMiners = available.map(availMiner => ({
...availMiner,
is_installed: installedMap.get(availMiner.name)?.is_installed ?? false,
}));
const installedMiners = (info.installed_miners_info || []).filter((m: InstallationDetails) => m.is_installed);
this.updateWhitelistPaths(installedMiners, []);
}),
catchError(err => {
this.handleError(err, 'Could not load miner information');
return of(null);
})
).subscribe();
}
private updateWhitelistPaths(installed: InstallationDetails[], running: any[]) {
whitelistPaths = computed(() => {
const paths = new Set<string>();
installed.forEach(miner => {
this.state().installedMiners.forEach(miner => {
if (miner.miner_binary) paths.add(miner.miner_binary);
if (miner.config_path) paths.add(miner.config_path);
});
running.forEach(miner => {
if (miner.configPath) paths.add(miner.configPath);
this.state().runningMiners.forEach(miner => {
if ((miner as any).configPath) paths.add((miner as any).configPath);
});
this.whitelistPaths = Array.from(paths);
}
return Array.from(paths);
});
installMiner(minerType: string): void {
this.actionInProgress = `install-${minerType}`;
this.error = null;
this.http.post(`${this.apiBaseUrl}/miners/${minerType}/install`, {}).subscribe({
next: () => {
setTimeout(() => {
this.getAdminData();
// A simple way to signal completion is to reload the page
// so the main dashboard component re-evaluates its state.
if (this.needsSetup) {
window.location.reload();
}
}, 1000);
},
error: (err: HttpErrorResponse) => this.handleError(err, `Failed to install ${minerType}`)
this.actionInProgress.set(`install-${minerType}`);
this.error.set(null);
this.minerService.installMiner(minerType).subscribe({
next: () => { this.actionInProgress.set(null); },
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to install ${minerType}`);
}
});
}
uninstallMiner(minerType: string): void {
this.actionInProgress = `uninstall-${minerType}`;
this.error = null;
this.http.delete(`${this.apiBaseUrl}/miners/${minerType}/uninstall`).subscribe({
next: () => {
setTimeout(() => {
this.getAdminData();
}, 1000);
},
error: (err: HttpErrorResponse) => this.handleError(err, `Failed to uninstall ${minerType}`)
this.actionInProgress.set(`uninstall-${minerType}`);
this.error.set(null);
this.minerService.uninstallMiner(minerType).subscribe({
next: () => { this.actionInProgress.set(null); },
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to uninstall ${minerType}`);
}
});
}
getMinerType(miner: any): string {
if (!miner.path) return 'unknown';
const parts = miner.path.split('/').filter((p: string) => p);
return parts.length > 1 ? parts[parts.length - 2] : parts[parts.length - 1] || 'unknown';
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
this.actionInProgress.set(null);
if (err.error && err.error.error) {
this.error.set(`${defaultMessage}: ${err.error.error}`);
} else if (typeof err.error === 'string' && err.error.length < 200) {
this.error.set(`${defaultMessage}: ${err.error}`);
} else {
this.error.set(`${defaultMessage}. Please check the console for details.`);
}
}
}

View file

@ -1,14 +1,15 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHighcharts } from 'highcharts-angular';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
provideHighcharts({
// Optional: Define the Highcharts instance dynamically
instance: () => import('highcharts'),

View file

@ -1,31 +1,10 @@
<div class="mining-dashboard">
<!-- The main container card that is ALWAYS present -->
<wa-card class="card-overview">
<div slot="header" class="card-header">
<div class="header-title">
<wa-icon name="cpu" style="font-size: 1.5rem;"></wa-icon>
<!-- Title changes based on state -->
@if (needsSetup) {
<span>Setup Required</span>
} @else {
<span>Mining Control</span>
}
</div>
@if (!needsSetup) {
<wa-button variant="neutral" appearance="plain" (click)="toggleAdminPanel()">
<wa-icon name="gear" label="Admin Panel"></wa-icon>
</wa-button>
}
</div>
<!-- Initial Loading State -->
@if (systemInfo === null && !needsSetup) {
@if (state().systemInfo === null && !state().needsSetup) {
<div class="centered-container">
<wa-spinner style="font-size: 3rem; margin-top: 1rem;"></wa-spinner>
<p>Connecting to API...</p>
</div>
} @else if (!apiAvailable) {
<!-- API Not Available State -->
} @else if (!state().apiAvailable) {
<div class="centered-container">
<p>API Not Available. Please ensure the mining service is running.</p>
<wa-button (click)="checkSystemState()">
@ -33,196 +12,9 @@
Retry
</wa-button>
</div>
} @else if (apiAvailable && needsSetup) {
<!-- Setup Wizard Content (re-integrated) -->
<div class="setup-wizard">
<p>To begin, please install a miner from the list below.</p>
<h4>Available Miners</h4>
<div class="miner-list">
@for (miner of manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
<wa-button
variant="success"
size="small"
[disabled]="actionInProgress === 'install-' + miner.name"
(click)="installMiner(miner.name)">
@if (actionInProgress === 'install-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="download" slot="prefix"></wa-icon>
Install
}
</wa-button>
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
</div>
} @else if (apiAvailable && !needsSetup) {
<!-- Main Content (when not in setup mode) -->
@if (error) {
<wa-card class="card-error">
<div slot="header">
<wa-icon name="exclamation-triangle" style="font-size: 1.5rem;"></wa-icon>
An Error Occurred
</div>
<p>{{ error }}</p>
</wa-card>
}
<!-- Admin Panel Content (re-integrated) -->
@if (showAdminPanel) {
<div class="admin-panel">
<h3 class="admin-title">Admin Panel</h3>
<h4>Manage Miners</h4>
<div class="miner-list">
@for (miner of manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
@if (miner.is_installed) {
<wa-button
variant="danger"
size="small"
[disabled]="actionInProgress === 'uninstall-' + miner.name"
(click)="uninstallMiner(miner.name)">
@if (actionInProgress === 'uninstall-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="trash" slot="prefix"></wa-icon>
Uninstall
}
</wa-button>
} @else {
<wa-button
variant="success"
size="small"
[disabled]="actionInProgress === 'install-' + miner.name"
(click)="installMiner(miner.name)">
@if (actionInProgress === 'install-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="download" slot="prefix"></wa-icon>
Install
}
</wa-button>
}
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
<h4>Antivirus Whitelist Paths</h4>
<div class="path-list">
<p>To prevent antivirus software from interfering, please add the following paths to your exclusion list:</p>
<ul>
@for (path of whitelistPaths; track path) {
<li><code>{{ path }}</code></li>
} @empty {
<li>No paths to display. Install a miner to see required paths.</li>
}
</ul>
</div>
</div>
} @else { <!-- Only show dashboard content if admin panel is not open -->
<!-- Miner Summary and Controls -->
@if (installedMiners.length > 0) {
<div class="dashboard-summary">
@for (miner of installedMiners; track miner.path) {
<div class="miner-summary-item">
<span class="miner-name">
{{ miner.type }}
<wa-tooltip>
<div slot="content">Version: {{ miner.version }}</div>
<wa-icon name="info-circle"></wa-icon>
</wa-tooltip>
</span>
@if (isMinerRunning(miner)) {
<wa-button
variant="danger"
[disabled]="actionInProgress === 'stop-' + miner.type"
(click)="stopMiner(miner)">
@if (actionInProgress === 'stop-' + miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="stop-circle" slot="prefix"></wa-icon>
Stop
}
</wa-button>
} @else {
<div class="start-buttons">
<wa-button
variant="secondary"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="startMiner(miner, true)">
@if (actionInProgress === 'start-' + miner.type && showStartOptionsFor !== miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="play-circle" slot="prefix"></wa-icon>
Start Last Config
}
</wa-button>
<wa-button
variant="primary"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="toggleStartOptions(miner.type)">
<wa-icon name="gear" slot="prefix"></wa-icon>
New Config
</wa-button>
</div>
}
</div>
@if (showStartOptionsFor === miner.type) {
<div class="start-options">
<wa-input label="Pool Address" [(ngModel)]="poolAddress" name="poolAddress"></wa-input>
<wa-input label="Wallet Address" [(ngModel)]="walletAddress" name="walletAddress"></wa-input>
<wa-button
variant="success"
[disabled]="actionInProgress === 'start-' + miner.type"
(click)="startMiner(miner)">
@if (actionInProgress === 'start-' + miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="rocket-launch" slot="prefix"></wa-icon>
Confirm & Start
}
</wa-button>
</div>
}
}
</div>
<!-- Charts for Running Miners -->
<div class="dashboard-charts">
@for (miner of installedMiners; track miner.path) {
@if (isMinerRunning(miner) && chartOptionsMap.has(getRunningMinerInstance(miner).name)) {
<div class="miner-chart-item">
<highcharts-chart
[constructorType]="chartConstructor"
[options]="chartOptionsMap.get(getRunningMinerInstance(miner).name)!"
[(update)]="updateFlag"
[oneToOne]="oneToOneFlag"
class="chart"
/>
</div>
}
}
</div>
} @else {
<div class="centered-container">
<p>No miners installed. Open the Admin Panel to install one.</p>
</div>
}
}
} @else if (state().apiAvailable && state().needsSetup) {
<snider-mining-setup-wizard></snider-mining-setup-wizard>
} @else if (state().apiAvailable && !state().needsSetup) {
<snider-mining-dashboard></snider-mining-dashboard>
}
</wa-card>
</div>

View file

@ -1,353 +1,34 @@
import {
Component,
OnInit,
OnDestroy,
ElementRef,
ViewEncapsulation,
CUSTOM_ELEMENTS_SCHEMA
} from '@angular/core';
import { HttpClient, HttpClientModule, HttpErrorResponse } from '@angular/common/http';
import { Component, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { of, forkJoin, Subscription, interval } from 'rxjs';
import { switchMap, catchError, map, startWith } from 'rxjs/operators';
import { HighchartsChartComponent, ChartConstructorType } from 'highcharts-angular'; // Corrected import
import * as Highcharts from 'highcharts';
import { MinerService } from './miner.service';
import { SetupWizardComponent } from './setup-wizard.component';
import { MiningDashboardComponent } from './dashboard.component';
// Import Web Awesome components
import "@awesome.me/webawesome/dist/webawesome.js";
import '@awesome.me/webawesome/dist/components/card/card.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/tooltip/tooltip.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/input/input.js';
// Define interfaces
interface InstallationDetails {
is_installed: boolean;
version: string;
path: string;
miner_binary: string;
config_path?: string;
type?: string;
}
interface AvailableMiner {
name: string;
description: string;
}
interface HashratePoint {
timestamp: string;
hashrate: number;
}
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@Component({
selector: 'snider-mining-dashboard',
selector: 'snider-mining',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, HttpClientModule, FormsModule, HighchartsChartComponent], // Corrected import
imports: [
CommonModule,
SetupWizardComponent,
MiningDashboardComponent
],
templateUrl: './app.html',
styleUrls: ["app.css"],
styleUrls: ['./app.css'],
encapsulation: ViewEncapsulation.ShadowDom
})
export class MiningDashboardElementComponent implements OnInit, OnDestroy {
apiBaseUrl: string = 'http://localhost:9090/api/v1/mining';
// State management
needsSetup: boolean = false;
apiAvailable: boolean = true;
error: string | null = null;
showAdminPanel: boolean = false;
actionInProgress: string | null = null;
systemInfo: any = null;
manageableMiners: any[] = [];
runningMiners: any[] = [];
installedMiners: InstallationDetails[] = [];
whitelistPaths: string[] = [];
// Charting
Highcharts: typeof Highcharts = Highcharts;
chartOptionsMap: Map<string, Highcharts.Options> = new Map();
chartConstructor: ChartConstructorType = 'chart';
updateFlag: boolean = false;
oneToOneFlag: boolean = true;
private statsSubscription: Subscription | undefined;
// Form inputs
poolAddress: string = 'pool.hashvault.pro:80';
walletAddress: string = '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H';
showStartOptionsFor: string | null = null;
constructor(private http: HttpClient, private elementRef: ElementRef) {}
ngOnInit(): void {
this.checkSystemState();
}
ngOnDestroy(): void {
this.stopStatsPolling();
}
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
this.actionInProgress = null;
if (err.error && err.error.error) {
this.error = `${defaultMessage}: ${err.error.error}`;
} else if (typeof err.error === 'string' && err.error.length < 200) {
this.error = `${defaultMessage}: ${err.error}`;
} else {
this.error = `${defaultMessage}. Please check the console for details.`;
}
}
export class SniderMining {
minerService = inject(MinerService);
state = this.minerService.state;
checkSystemState() {
this.error = null;
forkJoin({
available: this.http.get<AvailableMiner[]>(`${this.apiBaseUrl}/miners/available`),
info: this.http.get<any>(`${this.apiBaseUrl}/info`)
}).pipe(
switchMap(({ available, info }) => {
this.apiAvailable = true;
this.systemInfo = info;
const trulyInstalledMiners = (info.installed_miners_info || []).filter((m: InstallationDetails) => m.is_installed);
if (trulyInstalledMiners.length === 0) {
this.needsSetup = true;
this.manageableMiners = available.map(availMiner => ({ ...availMiner, is_installed: false }));
this.installedMiners = [];
this.runningMiners = [];
this.stopStatsPolling();
return of(null);
}
this.needsSetup = false;
const installedMap = new Map<string, InstallationDetails>((info.installed_miners_info || []).map((m: InstallationDetails) => [this.getMinerType(m), m]));
this.manageableMiners = available.map(availMiner => ({ ...availMiner, is_installed: installedMap.get(availMiner.name)?.is_installed ?? false }));
this.installedMiners = trulyInstalledMiners.map((m: InstallationDetails) => ({ ...m, type: this.getMinerType(m) }));
this.updateWhitelistPaths();
return this.fetchRunningMiners();
}),
catchError(err => {
if (err.status === 500) {
this.needsSetup = true;
this.fetchAvailableMinersForWizard();
} else {
this.apiAvailable = false;
this.error = 'Failed to connect to the mining API.';
}
this.systemInfo = {};
this.installedMiners = [];
this.runningMiners = [];
console.error('API not available or needs setup:', err);
return of(null);
})
).subscribe();
}
fetchAvailableMinersForWizard(): void {
this.http.get<AvailableMiner[]>(`${this.apiBaseUrl}/miners/available`).subscribe({
next: miners => { this.manageableMiners = miners.map(m => ({...m, is_installed: false})); },
error: err => { this.handleError(err, 'Could not fetch available miners for setup'); }
});
}
fetchRunningMiners() {
return this.http.get<any[]>(`${this.apiBaseUrl}/miners`).pipe(
map(miners => {
this.runningMiners = miners;
this.updateWhitelistPaths();
if (this.runningMiners.length > 0 && !this.statsSubscription) {
this.startStatsPolling();
} else if (this.runningMiners.length === 0) {
this.stopStatsPolling();
}
}),
catchError(err => {
this.handleError(err, 'Could not fetch running miners');
this.runningMiners = [];
return of([]);
})
);
}
startStatsPolling(): void {
this.stopStatsPolling();
this.statsSubscription = interval(5000).pipe(
startWith(0),
switchMap(() => forkJoin(
this.runningMiners.map(miner =>
this.http.get<HashratePoint[]>(`${this.apiBaseUrl}/miners/${miner.name}/hashrate-history`).pipe(
map(history => ({ name: miner.name, history })),
catchError(() => of({ name: miner.name, history: [] }))
)
)
))
).subscribe(results => {
results.forEach(result => {
this.updateChart(result.name, result.history);
});
});
}
stopStatsPolling(): void {
if (this.statsSubscription) {
this.statsSubscription.unsubscribe();
this.statsSubscription = undefined;
}
}
updateChart(minerName: string, history: HashratePoint[]): void {
const chartData = history.map(point => [new Date(point.timestamp).getTime(), point.hashrate]);
let options = this.chartOptionsMap.get(minerName);
if (!options) {
options = this.createChartOptions(minerName);
this.chartOptionsMap.set(minerName, options);
}
// Directly update the data property of the series
if (options.series && options.series.length > 0) {
const series = options.series[0] as Highcharts.SeriesLineOptions;
series.data = chartData;
}
// Trigger change detection by creating a new options object reference
// This is the correct way to make Highcharts detect updates when oneToOne is true
this.chartOptionsMap.set(minerName, { ...options });
// Toggle updateFlag to force re-render
this.updateFlag = !this.updateFlag;
}
createChartOptions(minerName: string): Highcharts.Options {
return {
chart: { type: 'spline' },
title: { text: `${minerName} Hashrate` },
xAxis: { type: 'datetime', title: { text: 'Time' } },
yAxis: { title: { text: 'Hashrate (H/s)' }, min: 0 },
series: [{ name: 'Hashrate', type: 'line', data: [] }],
credits: { enabled: false },
};
}
private updateWhitelistPaths() {
const paths = new Set<string>();
this.installedMiners.forEach(miner => {
if (miner.miner_binary) paths.add(miner.miner_binary);
if (miner.config_path) paths.add(miner.config_path);
});
this.runningMiners.forEach(miner => {
if (miner.configPath) paths.add(miner.configPath);
});
this.whitelistPaths = Array.from(paths);
}
installMiner(minerType: string): void {
this.actionInProgress = `install-${minerType}`;
this.error = null;
this.http.post(`${this.apiBaseUrl}/miners/${minerType}/install`, {}).subscribe({
next: () => {
setTimeout(() => {
this.checkSystemState();
this.actionInProgress = null;
}, 1000);
},
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to install ${minerType}`);
this.actionInProgress = null;
}
});
}
uninstallMiner(minerType: string): void {
this.actionInProgress = `uninstall-${minerType}`;
this.error = null;
this.http.delete(`${this.apiBaseUrl}/miners/${minerType}/uninstall`).subscribe({
next: () => {
setTimeout(() => {
this.checkSystemState();
this.actionInProgress = null;
}, 1000);
},
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to uninstall ${minerType}`);
this.actionInProgress = null;
}
});
}
startMiner(miner: any, useLastConfig: boolean = false): void {
this.actionInProgress = `start-${miner.type}`;
this.error = null;
let config = {};
if (!useLastConfig) {
config = {
pool: this.poolAddress,
wallet: this.walletAddress,
tls: true,
hugePages: true,
};
}
this.http.post(`${this.apiBaseUrl}/miners/${miner.type}`, config).subscribe({
next: () => {
setTimeout(() => {
this.checkSystemState();
this.actionInProgress = null;
}, 1000);
},
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to start ${miner.type}`);
this.actionInProgress = null;
}
});
this.showStartOptionsFor = null;
}
stopMiner(miner: any): void {
const runningInstance = this.getRunningMinerInstance(miner);
if (!runningInstance) {
this.error = "Cannot stop a miner that is not running.";
return;
}
this.actionInProgress = `stop-${miner.type}`;
this.error = null;
this.http.delete(`${this.apiBaseUrl}/miners/${runningInstance.name}`).subscribe({
next: () => {
setTimeout(() => {
this.checkSystemState();
this.actionInProgress = null;
}, 1000);
},
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to stop ${runningInstance.name}`);
this.actionInProgress = null;
}
});
}
toggleAdminPanel(): void {
this.showAdminPanel = !this.showAdminPanel;
}
toggleStartOptions(minerType: string | undefined): void {
if (!minerType) return;
this.showStartOptionsFor = this.showStartOptionsFor === minerType ? null : minerType;
}
getMinerType(miner: any): string {
if (!miner.path) return 'unknown';
const parts = miner.path.split('/').filter((p: string) => p);
return parts.length > 1 ? parts[parts.length - 2] : parts[parts.length - 1] || 'unknown';
}
getRunningMinerInstance(miner: any): any {
return this.runningMiners.find(m => m.name.startsWith(miner.type));
}
isMinerRunning(miner: any): boolean {
return !!this.getRunningMinerInstance(miner);
this.minerService.checkSystemState();
}
}

View file

@ -0,0 +1,5 @@
.chart {
width: 100%;
height: 300px;
display: block;
}

View file

@ -0,0 +1,12 @@
@if (chartOptions()) {
<highcharts-chart
[Highcharts]="Highcharts"
[constructorType]="chartConstructor"
[options]="chartOptions()"
[update]="updateFlag()"
[oneToOne]="true"
style="width: 100%; height: 400px; display: block;"
></highcharts-chart>
} @else {
<p>Loading chart...</p>
}

View file

@ -0,0 +1,135 @@
import { Component, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, inject, effect, signal, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HighchartsChartComponent, ChartConstructorType } from 'highcharts-angular';
import * as Highcharts from 'highcharts';
import { MinerService } from './miner.service';
// More specific type for series with data
type SeriesWithData = Highcharts.SeriesAreaOptions | Highcharts.SeriesSplineOptions;
@Component({
selector: 'snider-mining-chart',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, HighchartsChartComponent],
templateUrl: './chart.component.html',
styleUrls: ['./chart.component.css']
})
export class ChartComponent {
@Input() minerName?: string;
minerService = inject(MinerService);
Highcharts: typeof Highcharts = Highcharts;
chartConstructor: ChartConstructorType = 'chart';
chartOptions = signal<Highcharts.Options>({});
updateFlag = signal(false);
constructor() {
this.chartOptions.set(this.createBaseChartOptions());
effect(() => {
const historyMap = this.minerService.hashrateHistory();
let yAxisOptions: Highcharts.YAxisOptions = {};
if (this.minerName) {
// Single miner mode
const history = historyMap.get(this.minerName);
const chartData = history ? history.map(point => [new Date(point.timestamp).getTime(), point.hashrate]) : [];
yAxisOptions = this.calculateYAxisBoundsForSingle(chartData.map(d => d[1]));
this.chartOptions.update(options => ({
...options,
title: { text: `${this.minerName} Hashrate` },
chart: { type: 'spline' },
plotOptions: { area: undefined, spline: { marker: { enabled: false } } },
yAxis: { ...options.yAxis, ...yAxisOptions },
series: [{ type: 'spline', name: 'Hashrate', data: chartData }]
}));
} else {
// Overview mode
if (historyMap.size === 0) {
this.chartOptions.update(options => ({ ...options, series: [] }));
} else {
const newSeries: SeriesWithData[] = [];
historyMap.forEach((history, name) => {
const chartData = history.map(point => [new Date(point.timestamp).getTime(), point.hashrate]);
newSeries.push({ type: 'area', name: name, data: chartData });
});
yAxisOptions = this.calculateYAxisBoundsForStacked(newSeries);
this.chartOptions.update(options => ({
...options,
title: { text: 'Total Hashrate' },
chart: { type: 'area' },
plotOptions: { area: { stacking: 'normal', marker: { enabled: false } } },
yAxis: { ...options.yAxis, ...yAxisOptions },
series: newSeries
}));
}
}
this.updateFlag.update(flag => !flag);
});
}
private calculateYAxisBoundsForSingle(data: number[]): Highcharts.YAxisOptions {
if (data.length === 0) {
return { min: 0, max: undefined };
}
const min = Math.min(...data);
const max = Math.max(...data);
if (min === max) {
return { min: Math.max(0, min - 50), max: max + 50 };
}
const padding = (max - min) * 0.1; // 10% padding
return {
min: Math.max(0, min - padding),
max: max + padding
};
}
private calculateYAxisBoundsForStacked(series: SeriesWithData[]): Highcharts.YAxisOptions {
const totalsByTimestamp: { [key: number]: number } = {};
series.forEach(s => {
// Cast to any to avoid TS errors with union types where 'data' might be missing on some types
// even though we know SeriesWithData has it.
const data = (s as any).data;
if (data) {
(data as [number, number][]).forEach(([timestamp, value]) => {
totalsByTimestamp[timestamp] = (totalsByTimestamp[timestamp] || 0) + value;
});
}
});
const totalValues = Object.values(totalsByTimestamp);
if (totalValues.length === 0) {
return { min: 0, max: undefined };
}
const maxTotal = Math.max(...totalValues);
const padding = maxTotal * 0.1; // 10% padding on top
return {
min: 0, // Stacked chart should always start at 0
max: maxTotal + padding
};
}
createBaseChartOptions(): Highcharts.Options {
return {
xAxis: { type: 'datetime', title: { text: 'Time' } },
yAxis: { title: { text: 'Hashrate (H/s)' } }, // Remove min: 0 to allow dynamic scaling
series: [],
credits: { enabled: false },
accessibility: { enabled: false }
};
}
}

View file

@ -0,0 +1,94 @@
.admin-panel {
padding: 1rem;
}
.admin-title {
margin-top: 0;
}
.miner-list, .path-list {
margin-bottom: 1.5rem;
}
.miner-item, .path-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.path-list ul {
list-style-type: none;
padding: 0;
}
.path-list li {
background-color: #f5f5f5;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 0.5rem;
font-family: monospace;
}
.dashboard-summary {
padding: 1rem;
}
.miner-summary-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.miner-name {
font-weight: bold;
}
.start-buttons {
display: flex;
gap: 0.5rem;
}
.start-options {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.dashboard-charts {
padding: 1rem;
}
.miner-chart-item {
margin-bottom: 1.5rem;
}
.centered-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
}
.button-spinner {
font-size: 1rem;
}
.card-error {
margin-bottom: 1rem;
--wa-card-border-color: var(--wa-color-danger-border);
}
.card-error [slot="header"] {
color: var(--wa-color-danger-text);
}

View file

@ -0,0 +1,87 @@
<div class="dashboard-view">
<div class="header-title">
<wa-icon name="cpu" style="font-size: 1.5rem;"></wa-icon>
<span>Mining Control</span>
<wa-button variant="neutral" appearance="plain" (click)="toggleProfileManager()">
<wa-icon name="list-alt" label="Profile Manager"></wa-icon>
</wa-button>
</div>
@if (error()) {
<wa-card class="card-error">
<div slot="header">
<wa-icon name="exclamation-triangle" style="font-size: 1.5rem;"></wa-icon>
An Error Occurred
</div>
<p>{{ error() }}</p>
</wa-card>
}
@if(showProfileManager()) {
<div class="profile-manager-view">
<snider-mining-profile-list></snider-mining-profile-list>
<snider-mining-profile-create></snider-mining-profile-create>
</div>
} @else {
<!-- Miner Summary and Controls -->
@if (state().installedMiners.length > 0) {
<div class="dashboard-summary">
@for (miner of state().installedMiners; track miner.path) {
<div class="miner-summary-item">
<span class="miner-name">
{{ miner.type }}
<wa-tooltip>
<div slot="content">Version: {{ miner.version }}</div>
<wa-icon name="info-circle"></wa-icon>
</wa-tooltip>
</span>
@if (isMinerRunning(miner)) {
<wa-button
variant="danger"
[disabled]="actionInProgress() === 'stop-' + miner.type"
(click)="stopMiner(miner)">
@if (actionInProgress() === 'stop-' + miner.type) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="stop-circle" slot="prefix"></wa-icon>
Stop
}
</wa-button>
} @else {
<div class="start-buttons">
<wa-select [value]="selectedProfileId()" (waSelect)="handleProfileSelection($event)">
<span slot="label">Profile</span>
@for(profile of state().profiles; track profile.id) {
@if(profile.minerType === miner.type) {
<wa-option [value]="profile.id">{{ profile.name }}</wa-option>
}
}
</wa-select>
<wa-button
variant="primary"
[disabled]="!selectedProfileId() || actionInProgress()?.startsWith('start-')"
(click)="startMiner()">
<wa-icon name="play-circle" slot="prefix"></wa-icon>
Start
</wa-button>
</div>
}
</div>
}
</div>
<!-- Consolidated Chart for All Running Miners -->
@if (state().runningMiners.length > 0) {
<div class="dashboard-charts">
<snider-mining-chart></snider-mining-chart>
</div>
}
} @else {
<div class="centered-container">
<p>No miners installed. Open the Admin Panel to install one.</p>
</div>
}
}
</div>

View file

@ -0,0 +1,98 @@
import { Component, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { MinerService } from './miner.service';
import { ChartComponent } from './chart.component';
import { ProfileListComponent } from './profile-list.component';
import { ProfileCreateComponent } from './profile-create.component';
// Import Web Awesome components
import "@awesome.me/webawesome/dist/webawesome.js";
import '@awesome.me/webawesome/dist/components/card/card.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/tooltip/tooltip.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/input/input.js';
import '@awesome.me/webawesome/dist/components/select/select.js';
@Component({
selector: 'snider-mining-dashboard',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule, FormsModule, ChartComponent, ProfileListComponent, ProfileCreateComponent],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.css']
})
export class MiningDashboardComponent {
minerService = inject(MinerService);
state = this.minerService.state;
actionInProgress = signal<string | null>(null);
error = signal<string | null>(null);
showProfileManager = signal(false);
selectedProfileId = signal<string | null>(null);
handleProfileSelection(event: any) {
// The value is in the detail property of the custom event
this.selectedProfileId.set(event.detail.value);
}
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
this.actionInProgress.set(null);
if (err.error && err.error.error) {
this.error.set(`${defaultMessage}: ${err.error.error}`);
} else if (typeof err.error === 'string' && err.error.length < 200) {
this.error.set(`${defaultMessage}: ${err.error}`);
} else {
this.error.set(`${defaultMessage}. Please check the console for details.`);
}
}
startMiner(): void {
const profileId = this.selectedProfileId();
if (!profileId) {
this.error.set('Please select a profile to start.');
return;
}
this.actionInProgress.set(`start-${profileId}`);
this.error.set(null);
this.minerService.startMiner(profileId).subscribe({
next: () => { this.actionInProgress.set(null); },
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to start miner for profile ${profileId}`);
}
});
}
stopMiner(miner: any): void {
const runningInstance = this.getRunningMinerInstance(miner);
if (!runningInstance) {
this.error.set("Cannot stop a miner that is not running.");
return;
}
this.actionInProgress.set(`stop-${miner.type}`);
this.error.set(null);
this.minerService.stopMiner(runningInstance.name).subscribe({
next: () => { this.actionInProgress.set(null); },
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to stop ${runningInstance.name}`);
}
});
}
getRunningMinerInstance(miner: any): any {
return this.state().runningMiners.find((m: any) => m.name.startsWith(miner.type));
}
isMinerRunning(miner: any): boolean {
return !!this.getRunningMinerInstance(miner);
}
toggleProfileManager() {
this.showProfileManager.set(!this.showProfileManager());
}
}

241
ui/src/app/miner.service.ts Normal file
View file

@ -0,0 +1,241 @@
import { Injectable, OnDestroy, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { of, forkJoin, Subscription, interval } from 'rxjs';
import { switchMap, catchError, map, startWith, tap } from 'rxjs/operators';
// Define interfaces
export interface InstallationDetails {
is_installed: boolean;
version: string;
path: string;
miner_binary: string;
config_path?: string;
type?: string;
}
export interface AvailableMiner {
name: string;
description: string;
}
export interface HashratePoint {
timestamp: string;
hashrate: number;
}
export interface MiningProfile {
id: string;
name: string;
minerType: string;
config: any;
}
export interface SystemState {
needsSetup: boolean;
apiAvailable: boolean;
error: string | null;
systemInfo: any;
manageableMiners: any[];
installedMiners: InstallationDetails[];
runningMiners: any[];
profiles: MiningProfile[];
}
@Injectable({
providedIn: 'root'
})
export class MinerService implements OnDestroy {
private apiBaseUrl = 'http://localhost:9090/api/v1/mining';
// State Signals
public state = signal<SystemState>({
needsSetup: false,
apiAvailable: true,
error: null,
systemInfo: {},
manageableMiners: [],
installedMiners: [],
runningMiners: [],
profiles: []
});
public hashrateHistory = signal<Map<string, HashratePoint[]>>(new Map());
// Computed signals for convenience (optional, but helpful for components)
public runningMiners = computed(() => this.state().runningMiners);
public installedMiners = computed(() => this.state().installedMiners);
public apiAvailable = computed(() => this.state().apiAvailable);
public profiles = computed(() => this.state().profiles);
private pollingSubscription: Subscription | undefined;
constructor(private http: HttpClient) {
// Initial check
this.checkSystemState();
// Start polling for system state every 5 seconds for chart updates
this.pollingSubscription = interval(5000).subscribe(() => this.checkSystemState());
}
ngOnDestroy(): void {
if (this.pollingSubscription) {
this.pollingSubscription.unsubscribe();
}
}
checkSystemState() {
forkJoin({
available: this.getAvailableMiners().pipe(catchError(() => of([]))),
info: this.getSystemInfo().pipe(catchError(() => of({ installed_miners_info: [] }))),
running: this.getRunningMiners().pipe(catchError(() => of([]))), // This endpoint contains the history
profiles: this.getProfiles().pipe(catchError(() => of([])))
}).pipe(
map(({ available, info, running, profiles }) => {
const installedMap = new Map<string, InstallationDetails>();
(info.installed_miners_info || []).forEach((m: InstallationDetails) => {
if (m.is_installed) {
const type = this.getMinerType(m);
installedMap.set(type, { ...m, type });
}
});
running.forEach((miner: any) => {
const type = miner.name.split('-')[0];
if (!installedMap.has(type)) {
installedMap.set(type, {
is_installed: true,
version: 'unknown (running)',
path: 'unknown (running)',
miner_binary: 'unknown (running)',
type: type,
} as InstallationDetails);
}
});
const allInstalledMiners = Array.from(installedMap.values());
// Populate hashrate history directly from the running miners data
const newHistory = new Map<string, HashratePoint[]>();
running.forEach((miner: any) => {
if (miner.hashrateHistory) {
newHistory.set(miner.name, miner.hashrateHistory);
}
});
this.hashrateHistory.set(newHistory);
if (allInstalledMiners.length === 0) {
this.state.set({
needsSetup: true,
apiAvailable: true,
error: null,
systemInfo: info,
manageableMiners: available.map(availMiner => ({ ...availMiner, is_installed: false })),
installedMiners: [],
runningMiners: [],
profiles: profiles
});
return;
}
const manageableMiners = available.map(availMiner => ({
...availMiner,
is_installed: installedMap.has(availMiner.name),
}));
this.state.set({
needsSetup: false,
apiAvailable: true,
error: null,
systemInfo: info,
manageableMiners,
installedMiners: allInstalledMiners,
runningMiners: running,
profiles: profiles
});
}),
catchError(err => {
console.error('API not available or needs setup:', err);
this.hashrateHistory.set(new Map()); // Clear history on error
this.state.set({
needsSetup: false,
apiAvailable: false,
error: 'Failed to connect to the mining API.',
systemInfo: {},
manageableMiners: [],
installedMiners: [],
runningMiners: [],
profiles: []
});
return of(null);
})
).subscribe();
}
getAvailableMiners() {
return this.http.get<AvailableMiner[]>(`${this.apiBaseUrl}/miners/available`);
}
getSystemInfo() {
return this.http.get<any>(`${this.apiBaseUrl}/info`);
}
getRunningMiners() {
return this.http.get<any[]>(`${this.apiBaseUrl}/miners`);
}
getMinerHashrateHistory(minerName: string) {
return this.http.get<HashratePoint[]>(`${this.apiBaseUrl}/miners/${minerName}/hashrate-history`);
}
installMiner(minerType: string) {
return this.http.post(`${this.apiBaseUrl}/miners/${minerType}/install`, {}).pipe(
tap(() => setTimeout(() => this.checkSystemState(), 1000))
);
}
uninstallMiner(minerType: string) {
return this.http.delete(`${this.apiBaseUrl}/miners/${minerType}/uninstall`).pipe(
tap(() => setTimeout(() => this.checkSystemState(), 1000))
);
}
startMiner(profileId: string) {
return this.http.post(`${this.apiBaseUrl}/profiles/${profileId}/start`, {}).pipe(
tap(() => setTimeout(() => this.checkSystemState(), 1000))
);
}
stopMiner(minerName: string) {
return this.http.delete(`${this.apiBaseUrl}/miners/${minerName}`).pipe(
tap(() => setTimeout(() => this.checkSystemState(), 1000))
);
}
getProfiles() {
return this.http.get<MiningProfile[]>(`${this.apiBaseUrl}/profiles`);
}
createProfile(profile: MiningProfile) {
return this.http.post(`${this.apiBaseUrl}/profiles`, profile).pipe(
tap(() => setTimeout(() => this.checkSystemState(), 1000))
);
}
updateProfile(profile: MiningProfile) {
return this.http.put(`${this.apiBaseUrl}/profiles/${profile.id}`, profile).pipe(
tap(() => setTimeout(() => this.checkSystemState(), 1000))
);
}
deleteProfile(profileId: string) {
return this.http.delete(`${this.apiBaseUrl}/profiles/${profileId}`).pipe(
tap(() => setTimeout(() => this.checkSystemState(), 1000))
);
}
private getMinerType(miner: any): string {
if (!miner.path) return 'unknown';
const parts = miner.path.split('/').filter((p: string) => p);
return parts.length > 1 ? parts[parts.length - 2] : parts[parts.length - 1] || 'unknown';
}
}

View file

@ -0,0 +1,22 @@
.profile-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 0.25rem;
}
.card-error {
--wa-card-background-color: var(--wa-color-danger-50);
--wa-card-border-color: var(--wa-color-danger-300);
color: var(--wa-color-danger-800);
margin-bottom: 1rem;
}
.card-success {
--wa-card-background-color: var(--wa-color-success-50);
--wa-card-border-color: var(--wa-color-success-300);
color: var(--wa-color-success-800);
margin-bottom: 1rem;
}

View file

@ -0,0 +1,39 @@
<!--
Using ngForm and ngSubmit for a template-driven approach.
The form's state is now managed through the 'model' object in the component.
-->
<form class="profile-form" #profileForm="ngForm" (ngSubmit)="createProfile()">
<h5>Create New Profile</h5>
<!-- Error and success messages now use simple properties -->
@if (error) {
<wa-card class="card-error">
<p>{{ error }}</p>
</wa-card>
}
@if (success) {
<wa-card class="card-success">
<p>{{ success }}</p>
</wa-card>
}
<!-- Two-way data binding with [(ngModel)] -->
<wa-input name="name" label="Profile Name" [(ngModel)]="model.name" required></wa-input>
<wa-select name="minerType" label="Miner Type" [(ngModel)]="model.minerType" required>
@for(miner of state().manageableMiners; track miner.name) {
<wa-option [value]="miner.name">{{ miner.name }}</wa-option>
}
</wa-select>
<!-- ngModelGroup to group related form controls into a nested object -->
<fieldset ngModelGroup="config">
<legend>Configuration</legend>
<wa-input name="pool" label="Pool Address" [(ngModel)]="model.config.pool" required></wa-input>
<wa-input name="wallet" label="Wallet Address" [(ngModel)]="model.config.wallet" required></wa-input>
<wa-checkbox name="tls" [(ngModel)]="model.config.tls">TLS</wa-checkbox>
<wa-checkbox name="hugePages" [(ngModel)]="model.config.hugePages">Huge Pages</wa-checkbox>
</fieldset>
<wa-button type="submit">Create Profile</wa-button>
</form>

View file

@ -0,0 +1,83 @@
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpErrorResponse } from '@angular/common/http';
import { MinerService, MiningProfile } from './miner.service';
// Import Web Awesome components
import "@awesome.me/webawesome/dist/webawesome.js";
import '@awesome.me/webawesome/dist/components/input/input.js';
import '@awesome.me/webawesome/dist/components/select/select.js';
import '@awesome.me/webawesome/dist/components/checkbox/checkbox.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/card/card.js';
@Component({
selector: 'snider-mining-profile-create',
standalone: true,
imports: [CommonModule, FormsModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './profile-create.component.html',
styleUrls: ['./profile-create.component.css']
})
export class ProfileCreateComponent {
minerService = inject(MinerService);
state = this.minerService.state;
// Plain object model for the form
model: MiningProfile = {
id: '',
name: '',
minerType: '',
config: {
pool: '',
wallet: '',
tls: true,
hugePages: true
}
};
// Simple properties instead of signals
error: string | null = null;
success: string | null = null;
createProfile() {
this.error = null;
this.success = null;
// Basic validation check
if (!this.model.name || !this.model.minerType || !this.model.config.pool || !this.model.config.wallet) {
this.error = 'Please fill out all required fields.';
return;
}
this.minerService.createProfile(this.model).subscribe({
next: () => {
this.success = 'Profile created successfully!';
// Reset form to defaults
this.model = {
id: '',
name: '',
minerType: '',
config: {
pool: '',
wallet: '',
tls: true,
hugePages: true
}
};
setTimeout(() => this.success = null, 3000);
},
error: (err: HttpErrorResponse) => {
console.error(err);
if (err.error && err.error.error) {
this.error = `Failed to create profile: ${err.error.error}`;
} else if (typeof err.error === 'string' && err.error.length < 200) {
this.error = `Failed to create profile: ${err.error}`;
} else {
this.error = 'An unknown error occurred while creating the profile.';
}
}
});
}
}

View file

@ -0,0 +1,24 @@
.profile-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border: 1px solid #e0e0e0;
border-radius: 0.25rem;
}
.profile-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 0.25rem;
width: 100%;
}

View file

@ -0,0 +1,31 @@
<div class="profile-list">
<h5>Existing Profiles</h5>
@for(profile of state().profiles; track profile.id) {
<div class="profile-item">
@if(editingProfile && editingProfile.id === profile.id) {
<div class="profile-form">
<wa-input [(ngModel)]="editingProfile.name" label="Profile Name"></wa-input>
<wa-select [(ngModel)]="editingProfile.minerType" label="Miner Type">
@for(miner of state().manageableMiners; track miner.name) {
<wa-option [value]="miner.name">{{ miner.name }}</wa-option>
}
</wa-select>
<wa-input [(ngModel)]="editingProfile.config.pool" label="Pool Address"></wa-input>
<wa-input [(ngModel)]="editingProfile.config.wallet" label="Wallet Address"></wa-input>
<wa-checkbox [(ngModel)]="editingProfile.config.tls">TLS</wa-checkbox>
<wa-checkbox [(ngModel)]="editingProfile.config.hugePages">Huge Pages</wa-checkbox>
<wa-button (click)="updateProfile()">Save</wa-button>
<wa-button variant="neutral" (click)="cancelEdit()">Cancel</wa-button>
</div>
} @else {
<span>{{ profile.name }} ({{ profile.minerType }})</span>
<div>
<wa-button size="small" (click)="editProfile(profile)">Edit</wa-button>
<wa-button size="small" variant="danger" (click)="deleteProfile(profile.id)">Delete</wa-button>
</div>
}
</div>
} @empty {
<p>No profiles created yet.</p>
}
</div>

View file

@ -0,0 +1,38 @@
import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MinerService, MiningProfile } from './miner.service';
@Component({
selector: 'snider-mining-profile-list',
standalone: true,
imports: [CommonModule, FormsModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './profile-list.component.html',
styleUrls: ['./profile-list.component.css']
})
export class ProfileListComponent {
minerService = inject(MinerService);
state = this.minerService.state;
editingProfile: (MiningProfile & { config: any }) | null = null;
deleteProfile(profileId: string) {
this.minerService.deleteProfile(profileId).subscribe();
}
editProfile(profile: MiningProfile) {
this.editingProfile = { ...profile, config: { ...profile.config } };
}
updateProfile() {
if (!this.editingProfile) return;
this.minerService.updateProfile(this.editingProfile).subscribe(() => {
this.editingProfile = null;
});
}
cancelEdit() {
this.editingProfile = null;
}
}

View file

@ -0,0 +1,49 @@
:host {
display: block;
}
.setup-wizard {
display: flex;
flex-direction: column;
gap: 1rem;
}
.header-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: 600;
}
.miner-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
border: 1px solid var(--wa-color-neutral-300);
padding: 1rem;
border-radius: var(--wa-border-radius-medium);
}
.miner-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.button-spinner {
font-size: 1.2rem;
}
.card-error {
--wa-card-background-color: var(--wa-color-danger-50);
--wa-card-border-color: var(--wa-color-danger-300);
color: var(--wa-color-danger-800);
}
.card-error [slot="header"] {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}

View file

@ -0,0 +1,56 @@
<div class="setup-wizard">
<div class="header-title">
<wa-icon name="cpu" style="font-size: 1.5rem;"></wa-icon>
<span>Setup Required</span>
</div>
<p>To begin, please install a miner from the list below.</p>
<h4>Available Miners</h4>
<div class="miner-list">
@for (miner of state().manageableMiners; track miner.name) {
<div class="miner-item">
<span>{{ miner.name }}</span>
@if (miner.is_installed) {
<wa-button
variant="danger"
size="small"
[disabled]="actionInProgress() === 'uninstall-' + miner.name"
(click)="uninstallMiner(miner.name)">
@if (actionInProgress() === 'uninstall-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="trash" slot="prefix"></wa-icon>
Uninstall
}
</wa-button>
} @else {
<wa-button
variant="success"
size="small"
[disabled]="actionInProgress() === 'install-' + miner.name"
(click)="installMiner(miner.name)">
@if (actionInProgress() === 'install-' + miner.name) {
<wa-spinner class="button-spinner"></wa-spinner>
} @else {
<wa-icon name="download" slot="prefix"></wa-icon>
Install
}
</wa-button>
}
</div>
} @empty {
<div class="miner-item">
<span>Could not load available miners.</span>
</div>
}
</div>
@if (error()) {
<wa-card class="card-error">
<div slot="header">
<wa-icon name="exclamation-triangle" style="font-size: 1.5rem;"></wa-icon>
An Error Occurred
</div>
<p>{{ error() }}</p>
</wa-card>
}
</div>

View file

@ -0,0 +1,60 @@
import { Component, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import { MinerService } from './miner.service';
// Import Web Awesome components
import "@awesome.me/webawesome/dist/webawesome.js";
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/card/card.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@Component({
selector: 'snider-mining-setup-wizard',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [CommonModule],
templateUrl: './setup-wizard.component.html',
styleUrls: ['./setup-wizard.component.css']
})
export class SetupWizardComponent {
minerService = inject(MinerService);
state = this.minerService.state;
actionInProgress = signal<string | null>(null);
error = signal<string | null>(null);
installMiner(minerType: string): void {
this.actionInProgress.set(`install-${minerType}`);
this.error.set(null);
this.minerService.installMiner(minerType).subscribe({
next: () => { this.actionInProgress.set(null); },
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to install ${minerType}`);
}
});
}
uninstallMiner(minerType: string): void {
this.actionInProgress.set(`uninstall-${minerType}`);
this.error.set(null);
this.minerService.uninstallMiner(minerType).subscribe({
next: () => { this.actionInProgress.set(null); },
error: (err: HttpErrorResponse) => {
this.handleError(err, `Failed to uninstall ${minerType}`);
}
});
}
private handleError(err: HttpErrorResponse, defaultMessage: string) {
console.error(err);
this.actionInProgress.set(null);
if (err.error && err.error.error) {
this.error.set(`${defaultMessage}: ${err.error.error}`);
} else if (typeof err.error === 'string' && err.error.length < 200) {
this.error.set(`${defaultMessage}: ${err.error}`);
} else {
this.error.set(`${defaultMessage}. Please check the console for details.`);
}
}
}

1
ui/src/app/uuid.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module 'uuid';

View file

@ -1,14 +1,54 @@
<!doctype html>
<html lang="en" class="wa-theme-awesome wa-palette-bright wa-brand-blue">
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ui</title>
<title>Snider Mining</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<snider-mining-dashboard></snider-mining-dashboard>
<snider-mining-admin></snider-mining-admin>
<body class="bg-gray-100 text-gray-800">
<div class="flex flex-col h-screen">
<!-- Top Bar -->
<header class="flex items-center justify-between h-16 px-6 bg-white border-b">
<div>
<h2 class="text-xl font-semibold">Mining Dashboard (Dev View)</h2>
</div>
<div>
<p>User Info</p>
</div>
</header>
<!-- Main Content Area -->
<main class="flex-1 p-6 overflow-y-auto">
<div class="grid grid-cols-2 gap-6">
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-dashboard></snider-mining-dashboard>
</div>
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-admin></snider-mining-admin>
</div>
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-profile-create></snider-mining-profile-create>
</div>
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-setup-wizard></snider-mining-setup-wizard>
</div>
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-chart></snider-mining-chart>
</div>
<div class="bg-white shadow-md rounded-lg p-4">
<snider-mining-profile-list></snider-mining-profile-list>
</div>
</div>
</main>
<!-- Sticky Footer -->
<footer class="h-12 px-6 bg-white border-t flex items-center">
<p class="text-sm text-gray-600">&copy; 2025 Snider. All rights reserved.</p>
</footer>
</div>
</body>
</html>

View file

@ -1,19 +1,44 @@
import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { MiningDashboardElementComponent } from './app/app';
import { SniderMining } from './app/app';
import { MiningAdminComponent } from './app/admin.component';
import { SetupWizardComponent } from './app/setup-wizard.component';
import { MiningDashboardComponent } from './app/dashboard.component';
import { ChartComponent } from './app/chart.component';
import { ProfileListComponent } from './app/profile-list.component';
import { ProfileCreateComponent } from './app/profile-create.component';
import { appConfig } from './app/app.config';
(async () => {
const app = await createApplication(appConfig);
// Define the dashboard element as the primary application root
const DashboardElement = createCustomElement(MiningDashboardElementComponent, { injector: app.injector });
customElements.define('snider-mining-dashboard', DashboardElement);
console.log('snider-mining-dashboard custom element registered!');
// Define the main app element
const AppElement = createCustomElement(SniderMining, { injector: app.injector });
customElements.define('snider-mining', AppElement);
// // Define the admin element as a separate, secondary element
// Define the setup wizard element
const SetupWizardElement = createCustomElement(SetupWizardComponent, { injector: app.injector });
customElements.define('snider-mining-setup-wizard', SetupWizardElement);
// Define the dashboard view element
const DashboardElement = createCustomElement(MiningDashboardComponent, { injector: app.injector });
customElements.define('snider-mining-dashboard', DashboardElement);
// Define the chart element
const ChartElement = createCustomElement(ChartComponent, { injector: app.injector });
customElements.define('snider-mining-chart', ChartElement);
// Define the admin element as a separate, secondary element
const AdminElement = createCustomElement(MiningAdminComponent, { injector: app.injector });
customElements.define('snider-mining-admin', AdminElement);
console.log('snider-mining-admin custom element registered!');
// Define the profile list element
const ProfileListElement = createCustomElement(ProfileListComponent, { injector: app.injector });
customElements.define('snider-mining-profile-list', ProfileListElement);
// Define the profile create element
const ProfileCreateElement = createCustomElement(ProfileCreateComponent, { injector: app.injector });
customElements.define('snider-mining-profile-create', ProfileCreateElement);
console.log('All Snider Mining custom elements registered.');
})();