diff --git a/cmd/mining/cmd/serve.go b/cmd/mining/cmd/serve.go
index 9a9879b..26be02f 100644
--- a/cmd/mining/cmd/serve.go
+++ b/cmd/mining/cmd/serve.go
@@ -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() {
diff --git a/docs/docs.go b/docs/docs.go
index bf25ac1..cd764e4 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -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": {
diff --git a/docs/swagger.json b/docs/swagger.json
index cb296aa..432ed77 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -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": {
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index d06c079..a35e2e3 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -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
diff --git a/go.mod b/go.mod
index 3dad03d..c045921 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 0c32f66..25a5a2d 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/pkg/mining/mining_profile.go b/pkg/mining/mining_profile.go
index c95bdbb..1c0c906 100644
--- a/pkg/mining/mining_profile.go
+++ b/pkg/mining/mining_profile.go
@@ -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
}
diff --git a/pkg/mining/profile_manager.go b/pkg/mining/profile_manager.go
new file mode 100644
index 0000000..618ed66
--- /dev/null
+++ b/pkg/mining/profile_manager.go
@@ -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()
+}
diff --git a/pkg/mining/service.go b/pkg/mining/service.go
index 4ff8b65..c8a81cf 100644
--- a/pkg/mining/service.go
+++ b/pkg/mining/service.go
@@ -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"})
+}
diff --git a/pkg/mining/xmrig_start.go b/pkg/mining/xmrig_start.go
index 2f6993b..40e9fb2 100644
--- a/pkg/mining/xmrig_start.go
+++ b/pkg/mining/xmrig_start.go
@@ -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))
diff --git a/ui/src/app/admin.component.css b/ui/src/app/admin.component.css
index 525e947..6dbf71a 100644
--- a/ui/src/app/admin.component.css
+++ b/ui/src/app/admin.component.css
@@ -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 {
diff --git a/ui/src/app/admin.component.html b/ui/src/app/admin.component.html
index f6e2ed7..34b3e73 100644
--- a/ui/src/app/admin.component.html
+++ b/ui/src/app/admin.component.html
@@ -1,97 +1,63 @@
-
-@if (needsSetup) {
-
-
To begin, please install a miner from the list below.
-
Available Miners
-
- @for (miner of manageableMiners; track miner.name) {
-
-
{{ miner.name }}
+
+ @if (error()) {
+
+
+
+ An Error Occurred
+
+ {{ error() }}
+
+ }
+
+
Manage Miners
+
+ @for (miner of state().manageableMiners; track miner.name) {
+
+ {{ miner.name }}
+ @if (miner.is_installed) {
+
+ @if (actionInProgress() === 'uninstall-' + miner.name) {
+
+ } @else {
+
+ Uninstall
+ }
+
+ } @else {
- @if (actionInProgress === 'install-' + miner.name) {
+ @if (actionInProgress() === 'install-' + miner.name) {
} @else {
Install
}
-
- } @empty {
-
- Could not load available miners.
-
- }
-
-
-}
-
-
-@if (!needsSetup) {
-
- @if (error) {
-
-
-
- An Error Occurred
-
- {{ error }}
-
- }
-
-
Manage Miners
-
- @for (miner of manageableMiners; track miner.name) {
-
- {{ miner.name }}
- @if (miner.is_installed) {
-
- @if (actionInProgress === 'uninstall-' + miner.name) {
-
- } @else {
-
- Uninstall
- }
-
- } @else {
-
- @if (actionInProgress === 'install-' + miner.name) {
-
- } @else {
-
- Install
- }
-
- }
-
- } @empty {
-
- Could not load available miners.
-
- }
-
-
-
Antivirus Whitelist Paths
-
-
To prevent antivirus software from interfering, please add the following paths to your exclusion list:
-
- @for (path of whitelistPaths; track path) {
- {{ path }}
- } @empty {
- - No paths to display. Install a miner to see required paths.
}
-
-
+
+ } @empty {
+
+ Could not load available miners.
+
+ }
-}
+
+
Antivirus Whitelist Paths
+
+
To prevent antivirus software from interfering, please add the following paths to your exclusion list:
+
+ @for (path of whitelistPaths(); track path) {
+ {{ path }}
+ } @empty {
+ - No paths to display. Install a miner to see required paths.
+ }
+
+
+
diff --git a/ui/src/app/admin.component.ts b/ui/src/app/admin.component.ts
index 6f064ff..1a5b52d 100644
--- a/ui/src/app/admin.component.ts
+++ b/ui/src/app/admin.component.ts
@@ -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
(null);
+ error = signal(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(`${this.apiBaseUrl}/miners/available`),
- info: this.http.get(`${this.apiBaseUrl}/info`).pipe(catchError(() => of({}))) // Gracefully handle info error
- }).pipe(
- map(({ available, info }) => {
- const installedMap = new Map(
- (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();
- 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.`);
+ }
}
}
diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts
index 4f117cd..44c74ba 100644
--- a/ui/src/app/app.config.ts
+++ b/ui/src/app/app.config.ts
@@ -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'),
diff --git a/ui/src/app/app.html b/ui/src/app/app.html
index e654c25..2605a78 100644
--- a/ui/src/app/app.html
+++ b/ui/src/app/app.html
@@ -1,31 +1,10 @@
-
-
-
-
-
-
- @if (systemInfo === null && !needsSetup) {
+ @if (state().systemInfo === null && !state().needsSetup) {
- } @else if (!apiAvailable) {
-
+ } @else if (!state().apiAvailable) {
API Not Available. Please ensure the mining service is running.
@@ -33,196 +12,9 @@
Retry
- } @else if (apiAvailable && needsSetup) {
-
-
-
To begin, please install a miner from the list below.
-
Available Miners
-
- @for (miner of manageableMiners; track miner.name) {
-
- {{ miner.name }}
-
- @if (actionInProgress === 'install-' + miner.name) {
-
- } @else {
-
- Install
- }
-
-
- } @empty {
-
- Could not load available miners.
-
- }
-
-
- } @else if (apiAvailable && !needsSetup) {
-
- @if (error) {
-
-
-
- An Error Occurred
-
- {{ error }}
-
- }
-
-
- @if (showAdminPanel) {
-
-
Admin Panel
-
-
Manage Miners
-
- @for (miner of manageableMiners; track miner.name) {
-
- {{ miner.name }}
- @if (miner.is_installed) {
-
- @if (actionInProgress === 'uninstall-' + miner.name) {
-
- } @else {
-
- Uninstall
- }
-
- } @else {
-
- @if (actionInProgress === 'install-' + miner.name) {
-
- } @else {
-
- Install
- }
-
- }
-
- } @empty {
-
- Could not load available miners.
-
- }
-
-
-
Antivirus Whitelist Paths
-
-
To prevent antivirus software from interfering, please add the following paths to your exclusion list:
-
- @for (path of whitelistPaths; track path) {
- {{ path }}
- } @empty {
- - No paths to display. Install a miner to see required paths.
- }
-
-
-
- } @else {
-
- @if (installedMiners.length > 0) {
-
- @for (miner of installedMiners; track miner.path) {
-
-
- {{ miner.type }}
-
- Version: {{ miner.version }}
-
-
-
-
- @if (isMinerRunning(miner)) {
-
- @if (actionInProgress === 'stop-' + miner.type) {
-
- } @else {
-
- Stop
- }
-
- } @else {
-
-
- @if (actionInProgress === 'start-' + miner.type && showStartOptionsFor !== miner.type) {
-
- } @else {
-
- Start Last Config
- }
-
-
-
- New Config
-
-
- }
-
-
- @if (showStartOptionsFor === miner.type) {
-
-
-
-
- @if (actionInProgress === 'start-' + miner.type) {
-
- } @else {
-
- Confirm & Start
- }
-
-
- }
- }
-
-
-
-
- @for (miner of installedMiners; track miner.path) {
- @if (isMinerRunning(miner) && chartOptionsMap.has(getRunningMinerInstance(miner).name)) {
-
-
-
- }
- }
-
- } @else {
-
-
No miners installed. Open the Admin Panel to install one.
-
- }
- }
+ } @else if (state().apiAvailable && state().needsSetup) {
+
+ } @else if (state().apiAvailable && !state().needsSetup) {
+
}
-
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts
index 5a572f8..a4b71d7 100644
--- a/ui/src/app/app.ts
+++ b/ui/src/app/app.ts
@@ -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 = 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(`${this.apiBaseUrl}/miners/available`),
- info: this.http.get(`${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((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(`${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(`${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(`${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();
- 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();
}
}
diff --git a/ui/src/app/chart.component.css b/ui/src/app/chart.component.css
new file mode 100644
index 0000000..4b403f0
--- /dev/null
+++ b/ui/src/app/chart.component.css
@@ -0,0 +1,5 @@
+.chart {
+ width: 100%;
+ height: 300px;
+ display: block;
+}
diff --git a/ui/src/app/chart.component.html b/ui/src/app/chart.component.html
new file mode 100644
index 0000000..30fe18f
--- /dev/null
+++ b/ui/src/app/chart.component.html
@@ -0,0 +1,12 @@
+@if (chartOptions()) {
+
+} @else {
+ Loading chart...
+}
diff --git a/ui/src/app/chart.component.ts b/ui/src/app/chart.component.ts
new file mode 100644
index 0000000..1ea063c
--- /dev/null
+++ b/ui/src/app/chart.component.ts
@@ -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({});
+ 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 }
+ };
+ }
+}
diff --git a/ui/src/app/dashboard.component.css b/ui/src/app/dashboard.component.css
new file mode 100644
index 0000000..76a0616
--- /dev/null
+++ b/ui/src/app/dashboard.component.css
@@ -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);
+}
diff --git a/ui/src/app/dashboard.component.html b/ui/src/app/dashboard.component.html
new file mode 100644
index 0000000..67a3c43
--- /dev/null
+++ b/ui/src/app/dashboard.component.html
@@ -0,0 +1,87 @@
+
+
+
+ @if (error()) {
+
+
+
+ An Error Occurred
+
+ {{ error() }}
+
+ }
+
+ @if(showProfileManager()) {
+
+
+
+
+ } @else {
+
+ @if (state().installedMiners.length > 0) {
+
+ @for (miner of state().installedMiners; track miner.path) {
+
+
+ {{ miner.type }}
+
+ Version: {{ miner.version }}
+
+
+
+
+ @if (isMinerRunning(miner)) {
+
+ @if (actionInProgress() === 'stop-' + miner.type) {
+
+ } @else {
+
+ Stop
+ }
+
+ } @else {
+
+
+ Profile
+ @for(profile of state().profiles; track profile.id) {
+ @if(profile.minerType === miner.type) {
+ {{ profile.name }}
+ }
+ }
+
+
+
+ Start
+
+
+ }
+
+ }
+
+
+
+ @if (state().runningMiners.length > 0) {
+
+
+
+ }
+
+ } @else {
+
+
No miners installed. Open the Admin Panel to install one.
+
+ }
+ }
+
diff --git a/ui/src/app/dashboard.component.ts b/ui/src/app/dashboard.component.ts
new file mode 100644
index 0000000..6ef9019
--- /dev/null
+++ b/ui/src/app/dashboard.component.ts
@@ -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(null);
+ error = signal(null);
+
+ showProfileManager = signal(false);
+ selectedProfileId = signal(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());
+ }
+}
diff --git a/ui/src/app/miner.service.ts b/ui/src/app/miner.service.ts
new file mode 100644
index 0000000..e3d777e
--- /dev/null
+++ b/ui/src/app/miner.service.ts
@@ -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({
+ needsSetup: false,
+ apiAvailable: true,
+ error: null,
+ systemInfo: {},
+ manageableMiners: [],
+ installedMiners: [],
+ runningMiners: [],
+ profiles: []
+ });
+
+ public hashrateHistory = signal