feat: Add setup wizard and profile management components with styling
This commit is contained in:
parent
8f888a3749
commit
0d412e6faa
35 changed files with 2146 additions and 1436 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
440
docs/docs.go
440
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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
1
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
147
pkg/mining/profile_manager.go
Normal file
147
pkg/mining/profile_manager.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
ui/src/app/chart.component.css
Normal file
5
ui/src/app/chart.component.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.chart {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
display: block;
|
||||
}
|
||||
12
ui/src/app/chart.component.html
Normal file
12
ui/src/app/chart.component.html
Normal 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>
|
||||
}
|
||||
135
ui/src/app/chart.component.ts
Normal file
135
ui/src/app/chart.component.ts
Normal 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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
94
ui/src/app/dashboard.component.css
Normal file
94
ui/src/app/dashboard.component.css
Normal 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);
|
||||
}
|
||||
87
ui/src/app/dashboard.component.html
Normal file
87
ui/src/app/dashboard.component.html
Normal 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>
|
||||
98
ui/src/app/dashboard.component.ts
Normal file
98
ui/src/app/dashboard.component.ts
Normal 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
241
ui/src/app/miner.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
22
ui/src/app/profile-create.component.css
Normal file
22
ui/src/app/profile-create.component.css
Normal 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;
|
||||
}
|
||||
39
ui/src/app/profile-create.component.html
Normal file
39
ui/src/app/profile-create.component.html
Normal 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>
|
||||
83
ui/src/app/profile-create.component.ts
Normal file
83
ui/src/app/profile-create.component.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
24
ui/src/app/profile-list.component.css
Normal file
24
ui/src/app/profile-list.component.css
Normal 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%;
|
||||
}
|
||||
31
ui/src/app/profile-list.component.html
Normal file
31
ui/src/app/profile-list.component.html
Normal 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>
|
||||
38
ui/src/app/profile-list.component.ts
Normal file
38
ui/src/app/profile-list.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
49
ui/src/app/setup-wizard.component.css
Normal file
49
ui/src/app/setup-wizard.component.css
Normal 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;
|
||||
}
|
||||
56
ui/src/app/setup-wizard.component.html
Normal file
56
ui/src/app/setup-wizard.component.html
Normal 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>
|
||||
60
ui/src/app/setup-wizard.component.ts
Normal file
60
ui/src/app/setup-wizard.component.ts
Normal 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
1
ui/src/app/uuid.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
declare module 'uuid';
|
||||
|
|
@ -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">© 2025 Snider. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue