From 0d412e6faadbd97ea7a85234edbcb4ed894505f9 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 10 Dec 2025 22:17:38 +0000 Subject: [PATCH] feat: Add setup wizard and profile management components with styling --- cmd/mining/cmd/serve.go | 5 +- docs/docs.go | 440 +++++++++++------------ docs/swagger.json | 440 +++++++++++------------ docs/swagger.yaml | 295 +++++++-------- go.mod | 1 + go.sum | 2 + pkg/mining/mining_profile.go | 40 ++- pkg/mining/profile_manager.go | 147 ++++++++ pkg/mining/service.go | 181 +++++++--- pkg/mining/xmrig_start.go | 2 +- ui/src/app/admin.component.css | 1 + ui/src/app/admin.component.html | 136 +++---- ui/src/app/admin.component.ts | 148 +++----- ui/src/app/app.config.ts | 5 +- ui/src/app/app.html | 222 +----------- ui/src/app/app.ts | 353 +----------------- ui/src/app/chart.component.css | 5 + ui/src/app/chart.component.html | 12 + ui/src/app/chart.component.ts | 135 +++++++ ui/src/app/dashboard.component.css | 94 +++++ ui/src/app/dashboard.component.html | 87 +++++ ui/src/app/dashboard.component.ts | 98 +++++ ui/src/app/miner.service.ts | 241 +++++++++++++ ui/src/app/profile-create.component.css | 22 ++ ui/src/app/profile-create.component.html | 39 ++ ui/src/app/profile-create.component.ts | 83 +++++ ui/src/app/profile-list.component.css | 24 ++ ui/src/app/profile-list.component.html | 31 ++ ui/src/app/profile-list.component.ts | 38 ++ ui/src/app/setup-wizard.component.css | 49 +++ ui/src/app/setup-wizard.component.html | 56 +++ ui/src/app/setup-wizard.component.ts | 60 ++++ ui/src/app/uuid.d.ts | 1 + ui/src/index.html | 50 ++- ui/src/main.ts | 39 +- 35 files changed, 2146 insertions(+), 1436 deletions(-) create mode 100644 pkg/mining/profile_manager.go create mode 100644 ui/src/app/chart.component.css create mode 100644 ui/src/app/chart.component.html create mode 100644 ui/src/app/chart.component.ts create mode 100644 ui/src/app/dashboard.component.css create mode 100644 ui/src/app/dashboard.component.html create mode 100644 ui/src/app/dashboard.component.ts create mode 100644 ui/src/app/miner.service.ts create mode 100644 ui/src/app/profile-create.component.css create mode 100644 ui/src/app/profile-create.component.html create mode 100644 ui/src/app/profile-create.component.ts create mode 100644 ui/src/app/profile-list.component.css create mode 100644 ui/src/app/profile-list.component.html create mode 100644 ui/src/app/profile-list.component.ts create mode 100644 ui/src/app/setup-wizard.component.css create mode 100644 ui/src/app/setup-wizard.component.html create mode 100644 ui/src/app/setup-wizard.component.ts create mode 100644 ui/src/app/uuid.d.ts diff --git a/cmd/mining/cmd/serve.go b/cmd/mining/cmd/serve.go index 9a9879b..26be02f 100644 --- a/cmd/mining/cmd/serve.go +++ b/cmd/mining/cmd/serve.go @@ -43,7 +43,10 @@ var serveCmd = &cobra.Command{ // Use the global manager instance mgr := getManager() // This ensures we get the manager initialized by initManager - service := mining.NewService(mgr, listenAddr, displayAddr, namespace) // Pass the global manager + service, err := mining.NewService(mgr, listenAddr, displayAddr, namespace) // Pass the global manager + if err != nil { + return fmt.Errorf("failed to create new service: %w", err) + } // Start the server in a goroutine go func() { diff --git a/docs/docs.go b/docs/docs.go index bf25ac1..cd764e4 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -37,14 +37,14 @@ const docTemplate = `{ }, "/info": { "get": { - "description": "Retrieves the last cached installation details for all miners, along with system information.", + "description": "Retrieves live installation details for all miners, along with system information.", "produces": [ "application/json" ], "tags": [ "system" ], - "summary": "Get cached miner installation information", + "summary": "Get live miner installation information", "responses": { "200": { "description": "OK", @@ -203,47 +203,6 @@ const docTemplate = `{ } } }, - "/miners/{miner_type}": { - "post": { - "description": "Start a new miner with the given configuration", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "miners" - ], - "summary": "Start a new miner", - "parameters": [ - { - "type": "string", - "description": "Miner Type", - "name": "miner_type", - "in": "path", - "required": true - }, - { - "description": "Miner Configuration", - "name": "config", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/mining.Config" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/mining.XMRigMiner" - } - } - } - } - }, "/miners/{miner_type}/install": { "post": { "description": "Install a new miner or update an existing one.", @@ -308,6 +267,188 @@ const docTemplate = `{ } } }, + "/profiles": { + "get": { + "description": "Get a list of all saved mining profiles", + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "List all mining profiles", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + } + } + }, + "post": { + "description": "Create and save a new mining profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Create a new mining profile", + "parameters": [ + { + "description": "Mining Profile", + "name": "profile", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + } + } + }, + "/profiles/{id}": { + "get": { + "description": "Get a mining profile by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Get a specific mining profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + } + }, + "put": { + "description": "Update an existing mining profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Update a mining profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated Mining Profile", + "name": "profile", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + } + }, + "delete": { + "description": "Delete a mining profile by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Delete a mining profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/profiles/{id}/start": { + "post": { + "description": "Start a new miner with the configuration from a saved profile", + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Start a new miner using a profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mining.XMRigMiner" + } + } + } + } + }, "/update": { "post": { "description": "Checks if any installed miners have a new version available for download.", @@ -358,200 +499,6 @@ const docTemplate = `{ } } }, - "mining.Config": { - "type": "object", - "properties": { - "algo": { - "type": "string" - }, - "apiId": { - "type": "string" - }, - "apiWorkerId": { - "type": "string" - }, - "argon2Impl": { - "type": "string" - }, - "asm": { - "type": "string" - }, - "av": { - "type": "integer" - }, - "background": { - "type": "boolean" - }, - "bench": { - "type": "string" - }, - "coin": { - "type": "string" - }, - "cpuAffinity": { - "type": "string" - }, - "cpuMaxThreadsHint": { - "type": "integer" - }, - "cpuMemoryPool": { - "type": "integer" - }, - "cpuNoYield": { - "type": "boolean" - }, - "cpuPriority": { - "type": "integer" - }, - "donateLevel": { - "type": "integer" - }, - "donateOverProxy": { - "type": "boolean" - }, - "hash": { - "type": "string" - }, - "healthPrintTime": { - "type": "integer" - }, - "httpAccessToken": { - "type": "string" - }, - "httpHost": { - "type": "string" - }, - "httpNoRestricted": { - "type": "boolean" - }, - "httpPort": { - "type": "integer" - }, - "hugePages": { - "type": "boolean" - }, - "hugePagesJIT": { - "type": "boolean" - }, - "hugepageSize": { - "type": "integer" - }, - "keepalive": { - "type": "boolean" - }, - "logFile": { - "type": "string" - }, - "logOutput": { - "type": "boolean" - }, - "miner": { - "type": "string" - }, - "nicehash": { - "type": "boolean" - }, - "noColor": { - "type": "boolean" - }, - "noCpu": { - "type": "boolean" - }, - "noDMI": { - "type": "boolean" - }, - "noTitle": { - "type": "boolean" - }, - "password": { - "type": "string" - }, - "pauseOnActive": { - "type": "integer" - }, - "pauseOnBattery": { - "type": "boolean" - }, - "pool": { - "type": "string" - }, - "printTime": { - "type": "integer" - }, - "proxy": { - "type": "string" - }, - "randomX1GBPages": { - "type": "boolean" - }, - "randomXCacheQoS": { - "type": "boolean" - }, - "randomXInit": { - "type": "integer" - }, - "randomXMode": { - "type": "string" - }, - "randomXNoNuma": { - "type": "boolean" - }, - "randomXNoRdmsr": { - "type": "boolean" - }, - "randomXWrmsr": { - "type": "string" - }, - "retries": { - "type": "integer" - }, - "retryPause": { - "type": "integer" - }, - "rigId": { - "type": "string" - }, - "seed": { - "type": "string" - }, - "stress": { - "type": "boolean" - }, - "submit": { - "type": "boolean" - }, - "syslog": { - "type": "boolean" - }, - "threads": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "tls": { - "type": "boolean" - }, - "tlsFingerprint": { - "type": "string" - }, - "userAgent": { - "type": "string" - }, - "userPass": { - "type": "string" - }, - "verbose": { - "type": "boolean" - }, - "verify": { - "type": "string" - }, - "wallet": { - "type": "string" - } - } - }, "mining.HashratePoint": { "type": "object", "properties": { @@ -584,6 +531,25 @@ const docTemplate = `{ } } }, + "mining.MiningProfile": { + "type": "object", + "properties": { + "config": { + "description": "The raw JSON config for the specific miner", + "type": "object" + }, + "id": { + "type": "string" + }, + "minerType": { + "description": "e.g., \"xmrig\", \"ttminer\"", + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "mining.PerformanceMetrics": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index cb296aa..432ed77 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -31,14 +31,14 @@ }, "/info": { "get": { - "description": "Retrieves the last cached installation details for all miners, along with system information.", + "description": "Retrieves live installation details for all miners, along with system information.", "produces": [ "application/json" ], "tags": [ "system" ], - "summary": "Get cached miner installation information", + "summary": "Get live miner installation information", "responses": { "200": { "description": "OK", @@ -197,47 +197,6 @@ } } }, - "/miners/{miner_type}": { - "post": { - "description": "Start a new miner with the given configuration", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "miners" - ], - "summary": "Start a new miner", - "parameters": [ - { - "type": "string", - "description": "Miner Type", - "name": "miner_type", - "in": "path", - "required": true - }, - { - "description": "Miner Configuration", - "name": "config", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/mining.Config" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/mining.XMRigMiner" - } - } - } - } - }, "/miners/{miner_type}/install": { "post": { "description": "Install a new miner or update an existing one.", @@ -302,6 +261,188 @@ } } }, + "/profiles": { + "get": { + "description": "Get a list of all saved mining profiles", + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "List all mining profiles", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + } + } + }, + "post": { + "description": "Create and save a new mining profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Create a new mining profile", + "parameters": [ + { + "description": "Mining Profile", + "name": "profile", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + } + } + }, + "/profiles/{id}": { + "get": { + "description": "Get a mining profile by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Get a specific mining profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + } + }, + "put": { + "description": "Update an existing mining profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Update a mining profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated Mining Profile", + "name": "profile", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mining.MiningProfile" + } + } + } + }, + "delete": { + "description": "Delete a mining profile by its ID", + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Delete a mining profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/profiles/{id}/start": { + "post": { + "description": "Start a new miner with the configuration from a saved profile", + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "summary": "Start a new miner using a profile", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mining.XMRigMiner" + } + } + } + } + }, "/update": { "post": { "description": "Checks if any installed miners have a new version available for download.", @@ -352,200 +493,6 @@ } } }, - "mining.Config": { - "type": "object", - "properties": { - "algo": { - "type": "string" - }, - "apiId": { - "type": "string" - }, - "apiWorkerId": { - "type": "string" - }, - "argon2Impl": { - "type": "string" - }, - "asm": { - "type": "string" - }, - "av": { - "type": "integer" - }, - "background": { - "type": "boolean" - }, - "bench": { - "type": "string" - }, - "coin": { - "type": "string" - }, - "cpuAffinity": { - "type": "string" - }, - "cpuMaxThreadsHint": { - "type": "integer" - }, - "cpuMemoryPool": { - "type": "integer" - }, - "cpuNoYield": { - "type": "boolean" - }, - "cpuPriority": { - "type": "integer" - }, - "donateLevel": { - "type": "integer" - }, - "donateOverProxy": { - "type": "boolean" - }, - "hash": { - "type": "string" - }, - "healthPrintTime": { - "type": "integer" - }, - "httpAccessToken": { - "type": "string" - }, - "httpHost": { - "type": "string" - }, - "httpNoRestricted": { - "type": "boolean" - }, - "httpPort": { - "type": "integer" - }, - "hugePages": { - "type": "boolean" - }, - "hugePagesJIT": { - "type": "boolean" - }, - "hugepageSize": { - "type": "integer" - }, - "keepalive": { - "type": "boolean" - }, - "logFile": { - "type": "string" - }, - "logOutput": { - "type": "boolean" - }, - "miner": { - "type": "string" - }, - "nicehash": { - "type": "boolean" - }, - "noColor": { - "type": "boolean" - }, - "noCpu": { - "type": "boolean" - }, - "noDMI": { - "type": "boolean" - }, - "noTitle": { - "type": "boolean" - }, - "password": { - "type": "string" - }, - "pauseOnActive": { - "type": "integer" - }, - "pauseOnBattery": { - "type": "boolean" - }, - "pool": { - "type": "string" - }, - "printTime": { - "type": "integer" - }, - "proxy": { - "type": "string" - }, - "randomX1GBPages": { - "type": "boolean" - }, - "randomXCacheQoS": { - "type": "boolean" - }, - "randomXInit": { - "type": "integer" - }, - "randomXMode": { - "type": "string" - }, - "randomXNoNuma": { - "type": "boolean" - }, - "randomXNoRdmsr": { - "type": "boolean" - }, - "randomXWrmsr": { - "type": "string" - }, - "retries": { - "type": "integer" - }, - "retryPause": { - "type": "integer" - }, - "rigId": { - "type": "string" - }, - "seed": { - "type": "string" - }, - "stress": { - "type": "boolean" - }, - "submit": { - "type": "boolean" - }, - "syslog": { - "type": "boolean" - }, - "threads": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "tls": { - "type": "boolean" - }, - "tlsFingerprint": { - "type": "string" - }, - "userAgent": { - "type": "string" - }, - "userPass": { - "type": "string" - }, - "verbose": { - "type": "boolean" - }, - "verify": { - "type": "string" - }, - "wallet": { - "type": "string" - } - } - }, "mining.HashratePoint": { "type": "object", "properties": { @@ -578,6 +525,25 @@ } } }, + "mining.MiningProfile": { + "type": "object", + "properties": { + "config": { + "description": "The raw JSON config for the specific miner", + "type": "object" + }, + "id": { + "type": "string" + }, + "minerType": { + "description": "e.g., \"xmrig\", \"ttminer\"", + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "mining.PerformanceMetrics": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d06c079..a35e2e3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -16,135 +16,6 @@ definitions: name: type: string type: object - mining.Config: - properties: - algo: - type: string - apiId: - type: string - apiWorkerId: - type: string - argon2Impl: - type: string - asm: - type: string - av: - type: integer - background: - type: boolean - bench: - type: string - coin: - type: string - cpuAffinity: - type: string - cpuMaxThreadsHint: - type: integer - cpuMemoryPool: - type: integer - cpuNoYield: - type: boolean - cpuPriority: - type: integer - donateLevel: - type: integer - donateOverProxy: - type: boolean - hash: - type: string - healthPrintTime: - type: integer - httpAccessToken: - type: string - httpHost: - type: string - httpNoRestricted: - type: boolean - httpPort: - type: integer - hugePages: - type: boolean - hugePagesJIT: - type: boolean - hugepageSize: - type: integer - keepalive: - type: boolean - logFile: - type: string - logOutput: - type: boolean - miner: - type: string - nicehash: - type: boolean - noColor: - type: boolean - noCpu: - type: boolean - noDMI: - type: boolean - noTitle: - type: boolean - password: - type: string - pauseOnActive: - type: integer - pauseOnBattery: - type: boolean - pool: - type: string - printTime: - type: integer - proxy: - type: string - randomX1GBPages: - type: boolean - randomXCacheQoS: - type: boolean - randomXInit: - type: integer - randomXMode: - type: string - randomXNoNuma: - type: boolean - randomXNoRdmsr: - type: boolean - randomXWrmsr: - type: string - retries: - type: integer - retryPause: - type: integer - rigId: - type: string - seed: - type: string - stress: - type: boolean - submit: - type: boolean - syslog: - type: boolean - threads: - type: integer - title: - type: string - tls: - type: boolean - tlsFingerprint: - type: string - userAgent: - type: string - userPass: - type: string - verbose: - type: boolean - verify: - type: string - wallet: - type: string - type: object mining.HashratePoint: properties: hashrate: @@ -166,6 +37,19 @@ definitions: version: type: string type: object + mining.MiningProfile: + properties: + config: + description: The raw JSON config for the specific miner + type: object + id: + type: string + minerType: + description: e.g., "xmrig", "ttminer" + type: string + name: + type: string + type: object mining.PerformanceMetrics: properties: algorithm: @@ -253,8 +137,8 @@ paths: - system /info: get: - description: Retrieves the last cached installation details for all miners, - along with system information. + description: Retrieves live installation details for all miners, along with + system information. produces: - application/json responses: @@ -268,7 +152,7 @@ paths: additionalProperties: type: string type: object - summary: Get cached miner installation information + summary: Get live miner installation information tags: - system /miners: @@ -347,33 +231,6 @@ paths: summary: Get miner stats tags: - miners - /miners/{miner_type}: - post: - consumes: - - application/json - description: Start a new miner with the given configuration - parameters: - - description: Miner Type - in: path - name: miner_type - required: true - type: string - - description: Miner Configuration - in: body - name: config - required: true - schema: - $ref: '#/definitions/mining.Config' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/mining.XMRigMiner' - summary: Start a new miner - tags: - - miners /miners/{miner_type}/install: post: description: Install a new miner or update an existing one. @@ -431,6 +288,126 @@ paths: summary: List all available miners tags: - miners + /profiles: + get: + description: Get a list of all saved mining profiles + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/mining.MiningProfile' + type: array + summary: List all mining profiles + tags: + - profiles + post: + consumes: + - application/json + description: Create and save a new mining profile + parameters: + - description: Mining Profile + in: body + name: profile + required: true + schema: + $ref: '#/definitions/mining.MiningProfile' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/mining.MiningProfile' + summary: Create a new mining profile + tags: + - profiles + /profiles/{id}: + delete: + description: Delete a mining profile by its ID + parameters: + - description: Profile ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Delete a mining profile + tags: + - profiles + get: + description: Get a mining profile by its ID + parameters: + - description: Profile ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mining.MiningProfile' + summary: Get a specific mining profile + tags: + - profiles + put: + consumes: + - application/json + description: Update an existing mining profile + parameters: + - description: Profile ID + in: path + name: id + required: true + type: string + - description: Updated Mining Profile + in: body + name: profile + required: true + schema: + $ref: '#/definitions/mining.MiningProfile' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mining.MiningProfile' + summary: Update a mining profile + tags: + - profiles + /profiles/{id}/start: + post: + description: Start a new miner with the configuration from a saved profile + parameters: + - description: Profile ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mining.XMRigMiner' + summary: Start a new miner using a profile + tags: + - profiles /update: post: description: Checks if any installed miners have a new version available for diff --git a/go.mod b/go.mod index 3dad03d..c045921 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/adrg/xdg v0.5.3 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.11.0 + github.com/google/uuid v1.6.0 github.com/shirou/gopsutil/v4 v4.25.10 github.com/spf13/cobra v1.8.1 github.com/swaggo/files v1.0.1 diff --git a/go.sum b/go.sum index 0c32f66..25a5a2d 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/pkg/mining/mining_profile.go b/pkg/mining/mining_profile.go index c95bdbb..1c0c906 100644 --- a/pkg/mining/mining_profile.go +++ b/pkg/mining/mining_profile.go @@ -1,12 +1,36 @@ package mining -// MiningProfile represents a saved configuration for a specific mining setup. -// This allows users to define and switch between different miners, pools, -// and wallets without re-entering information. +import ( + "errors" +) + +// RawConfig is a raw encoded JSON value. +// It implements Marshaler and Unmarshaler and can be used to delay JSON decoding or precompute a JSON encoding. +// We define it as []byte (like json.RawMessage) to avoid swagger parsing issues with the json package. +type RawConfig []byte + +// MiningProfile represents a saved configuration for running a specific miner. +// It decouples the UI from the underlying miner's specific config structure. type MiningProfile struct { - Name string `json:"name"` // A user-defined name for the profile, e.g., "My XMR Rig" - Pool string `json:"pool"` // The mining pool address - Wallet string `json:"wallet"` // The wallet address - Miner string `json:"miner"` // The type of miner, e.g., "xmrig" - // This can be expanded later to include the full *Config for advanced options + ID string `json:"id"` + Name string `json:"name"` + MinerType string `json:"minerType"` // e.g., "xmrig", "ttminer" + Config RawConfig `json:"config" swaggertype:"object"` // The raw JSON config for the specific miner +} + +// MarshalJSON returns m as the JSON encoding of m. +func (m RawConfig) MarshalJSON() ([]byte, error) { + if m == nil { + return []byte("null"), nil + } + return m, nil +} + +// UnmarshalJSON sets *m to a copy of data. +func (m *RawConfig) UnmarshalJSON(data []byte) error { + if m == nil { + return errors.New("RawConfig: UnmarshalJSON on nil pointer") + } + *m = append((*m)[0:0], data...) + return nil } diff --git a/pkg/mining/profile_manager.go b/pkg/mining/profile_manager.go new file mode 100644 index 0000000..618ed66 --- /dev/null +++ b/pkg/mining/profile_manager.go @@ -0,0 +1,147 @@ +package mining + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/adrg/xdg" + "github.com/google/uuid" +) + +const profileConfigFileName = "mining_profiles.json" + +// ProfileManager handles CRUD operations for MiningProfiles. +type ProfileManager struct { + mu sync.RWMutex + profiles map[string]*MiningProfile + configPath string +} + +// NewProfileManager creates and initializes a new ProfileManager. +func NewProfileManager() (*ProfileManager, error) { + configPath, err := xdg.ConfigFile(filepath.Join("lethean-desktop", profileConfigFileName)) + if err != nil { + return nil, fmt.Errorf("could not resolve config path: %w", err) + } + + pm := &ProfileManager{ + profiles: make(map[string]*MiningProfile), + configPath: configPath, + } + + if err := pm.loadProfiles(); err != nil { + // If the file doesn't exist, that's fine, but any other error is a problem. + if !os.IsNotExist(err) { + return nil, fmt.Errorf("could not load profiles: %w", err) + } + } + + return pm, nil +} + +// loadProfiles reads the profiles from the JSON file into memory. +func (pm *ProfileManager) loadProfiles() error { + pm.mu.Lock() + defer pm.mu.Unlock() + + data, err := os.ReadFile(pm.configPath) + if err != nil { + return err + } + + var profiles []*MiningProfile + if err := json.Unmarshal(data, &profiles); err != nil { + return err + } + + pm.profiles = make(map[string]*MiningProfile) + for _, p := range profiles { + pm.profiles[p.ID] = p + } + + return nil +} + +// saveProfiles writes the current profiles from memory to the JSON file. +func (pm *ProfileManager) saveProfiles() error { + pm.mu.RLock() + defer pm.mu.RUnlock() + + profileList := make([]*MiningProfile, 0, len(pm.profiles)) + for _, p := range pm.profiles { + profileList = append(profileList, p) + } + + data, err := json.MarshalIndent(profileList, "", " ") + if err != nil { + return err + } + + return os.WriteFile(pm.configPath, data, 0644) +} + +// CreateProfile adds a new profile and saves it. +func (pm *ProfileManager) CreateProfile(profile *MiningProfile) (*MiningProfile, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + profile.ID = uuid.New().String() + pm.profiles[profile.ID] = profile + + if err := pm.saveProfiles(); err != nil { + // Rollback + delete(pm.profiles, profile.ID) + return nil, err + } + + return profile, nil +} + +// GetProfile retrieves a profile by its ID. +func (pm *ProfileManager) GetProfile(id string) (*MiningProfile, bool) { + pm.mu.RLock() + defer pm.mu.RUnlock() + profile, exists := pm.profiles[id] + return profile, exists +} + +// GetAllProfiles returns a list of all profiles. +func (pm *ProfileManager) GetAllProfiles() []*MiningProfile { + pm.mu.RLock() + defer pm.mu.RUnlock() + + profileList := make([]*MiningProfile, 0, len(pm.profiles)) + for _, p := range pm.profiles { + profileList = append(profileList, p) + } + return profileList +} + +// UpdateProfile modifies an existing profile. +func (pm *ProfileManager) UpdateProfile(profile *MiningProfile) error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if _, exists := pm.profiles[profile.ID]; !exists { + return fmt.Errorf("profile with ID %s not found", profile.ID) + } + pm.profiles[profile.ID] = profile + + return pm.saveProfiles() +} + +// DeleteProfile removes a profile by its ID. +func (pm *ProfileManager) DeleteProfile(id string) error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if _, exists := pm.profiles[id]; !exists { + return fmt.Errorf("profile with ID %s not found", id) + } + delete(pm.profiles, id) + + return pm.saveProfiles() +} diff --git a/pkg/mining/service.go b/pkg/mining/service.go index 4ff8b65..c8a81cf 100644 --- a/pkg/mining/service.go +++ b/pkg/mining/service.go @@ -27,6 +27,7 @@ import ( // Service encapsulates the gin-gonic router and the mining manager. type Service struct { Manager ManagerInterface + ProfileManager *ProfileManager Router *gin.Engine Server *http.Server DisplayAddr string @@ -36,7 +37,7 @@ type Service struct { } // NewService creates a new mining service -func NewService(manager ManagerInterface, listenAddr string, displayAddr string, swaggerNamespace string) *Service { +func NewService(manager ManagerInterface, listenAddr string, displayAddr string, swaggerNamespace string) (*Service, error) { apiBasePath := "/" + strings.Trim(swaggerNamespace, "/") swaggerUIPath := apiBasePath + "/swagger" @@ -47,8 +48,14 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string, instanceName := "swagger_" + strings.ReplaceAll(strings.Trim(swaggerNamespace, "/"), "/", "_") swag.Register(instanceName, docs.SwaggerInfo) + profileManager, err := NewProfileManager() + if err != nil { + return nil, fmt.Errorf("failed to initialize profile manager: %w", err) + } + return &Service{ - Manager: manager, + Manager: manager, + ProfileManager: profileManager, Server: &http.Server{ Addr: listenAddr, }, @@ -56,7 +63,7 @@ func NewService(manager ManagerInterface, listenAddr string, displayAddr string, SwaggerInstanceName: instanceName, APIBasePath: apiBasePath, SwaggerUIPath: swaggerUIPath, - } + }, nil } func (s *Service) ServiceStartup(ctx context.Context) error { @@ -95,13 +102,22 @@ func (s *Service) setupRoutes() { { minersGroup.GET("", s.handleListMiners) minersGroup.GET("/available", s.handleListAvailableMiners) - minersGroup.POST("/:miner_name", s.handleStartMiner) minersGroup.POST("/:miner_name/install", s.handleInstallMiner) minersGroup.DELETE("/:miner_name/uninstall", s.handleUninstallMiner) minersGroup.DELETE("/:miner_name", s.handleStopMiner) minersGroup.GET("/:miner_name/stats", s.handleGetMinerStats) minersGroup.GET("/:miner_name/hashrate-history", s.handleGetMinerHashrateHistory) } + + profilesGroup := apiGroup.Group("/profiles") + { + profilesGroup.GET("", s.handleListProfiles) + profilesGroup.POST("", s.handleCreateProfile) + profilesGroup.GET("/:id", s.handleGetProfile) + profilesGroup.PUT("/:id", s.handleUpdateProfile) + profilesGroup.DELETE("/:id", s.handleDeleteProfile) + profilesGroup.POST("/:id/start", s.handleStartMinerWithProfile) + } } s.Router.StaticFile("/component/mining-dashboard.js", "./ui/dist/ui/mbe-mining-dashboard.js") @@ -111,37 +127,19 @@ func (s *Service) setupRoutes() { } // handleGetInfo godoc -// @Summary Get cached miner installation information -// @Description Retrieves the last cached installation details for all miners, along with system information. +// @Summary Get live miner installation information +// @Description Retrieves live installation details for all miners, along with system information. // @Tags system // @Produce json // @Success 200 {object} SystemInfo // @Failure 500 {object} map[string]string "Internal server error" // @Router /info [get] func (s *Service) handleGetInfo(c *gin.Context) { - configDir, err := xdg.ConfigFile("lethean-desktop/miners") + systemInfo, err := s.updateInstallationCache() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get config directory"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get system info", "details": err.Error()}) return } - configPath := filepath.Join(configDir, "config.json") - - cacheBytes, err := os.ReadFile(configPath) - if err != nil { - if os.IsNotExist(err) { - c.JSON(http.StatusInternalServerError, gin.H{"error": "cache file not found, run setup"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read cache file"}) - return - } - - var systemInfo SystemInfo - if err := json.Unmarshal(cacheBytes, &systemInfo); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "could not parse cache file"}) - return - } - c.JSON(http.StatusOK, systemInfo) } @@ -343,24 +341,29 @@ func (s *Service) handleInstallMiner(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "installed", "version": details.Version, "path": details.Path}) } -// handleStartMiner godoc -// @Summary Start a new miner -// @Description Start a new miner with the given configuration -// @Tags miners -// @Accept json +// handleStartMinerWithProfile godoc +// @Summary Start a new miner using a profile +// @Description Start a new miner with the configuration from a saved profile +// @Tags profiles // @Produce json -// @Param miner_type path string true "Miner Type" -// @Param config body Config true "Miner Configuration" +// @Param id path string true "Profile ID" // @Success 200 {object} XMRigMiner -// @Router /miners/{miner_type} [post] -func (s *Service) handleStartMiner(c *gin.Context) { - minerType := c.Param("miner_name") - var config Config - if err := c.ShouldBindJSON(&config); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) +// @Router /profiles/{id}/start [post] +func (s *Service) handleStartMinerWithProfile(c *gin.Context) { + profileID := c.Param("id") + profile, exists := s.ProfileManager.GetProfile(profileID) + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) return } - miner, err := s.Manager.StartMiner(minerType, &config) + + var config Config + if err := json.Unmarshal(profile.Config, &config); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse profile config", "details": err.Error()}) + return + } + + miner, err := s.Manager.StartMiner(profile.MinerType, &config) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -425,3 +428,101 @@ func (s *Service) handleGetMinerHashrateHistory(c *gin.Context) { } c.JSON(http.StatusOK, history) } + +// handleListProfiles godoc +// @Summary List all mining profiles +// @Description Get a list of all saved mining profiles +// @Tags profiles +// @Produce json +// @Success 200 {array} MiningProfile +// @Router /profiles [get] +func (s *Service) handleListProfiles(c *gin.Context) { + profiles := s.ProfileManager.GetAllProfiles() + c.JSON(http.StatusOK, profiles) +} + +// handleCreateProfile godoc +// @Summary Create a new mining profile +// @Description Create and save a new mining profile +// @Tags profiles +// @Accept json +// @Produce json +// @Param profile body MiningProfile true "Mining Profile" +// @Success 201 {object} MiningProfile +// @Router /profiles [post] +func (s *Service) handleCreateProfile(c *gin.Context) { + var profile MiningProfile + if err := c.ShouldBindJSON(&profile); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + createdProfile, err := s.ProfileManager.CreateProfile(&profile) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create profile", "details": err.Error()}) + return + } + + c.JSON(http.StatusCreated, createdProfile) +} + +// handleGetProfile godoc +// @Summary Get a specific mining profile +// @Description Get a mining profile by its ID +// @Tags profiles +// @Produce json +// @Param id path string true "Profile ID" +// @Success 200 {object} MiningProfile +// @Router /profiles/{id} [get] +func (s *Service) handleGetProfile(c *gin.Context) { + profileID := c.Param("id") + profile, exists := s.ProfileManager.GetProfile(profileID) + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"}) + return + } + c.JSON(http.StatusOK, profile) +} + +// handleUpdateProfile godoc +// @Summary Update a mining profile +// @Description Update an existing mining profile +// @Tags profiles +// @Accept json +// @Produce json +// @Param id path string true "Profile ID" +// @Param profile body MiningProfile true "Updated Mining Profile" +// @Success 200 {object} MiningProfile +// @Router /profiles/{id} [put] +func (s *Service) handleUpdateProfile(c *gin.Context) { + profileID := c.Param("id") + var profile MiningProfile + if err := c.ShouldBindJSON(&profile); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + profile.ID = profileID + + if err := s.ProfileManager.UpdateProfile(&profile); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, profile) +} + +// handleDeleteProfile godoc +// @Summary Delete a mining profile +// @Description Delete a mining profile by its ID +// @Tags profiles +// @Produce json +// @Param id path string true "Profile ID" +// @Success 200 {object} map[string]string +// @Router /profiles/{id} [delete] +func (s *Service) handleDeleteProfile(c *gin.Context) { + profileID := c.Param("id") + if err := s.ProfileManager.DeleteProfile(profileID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete profile", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "profile deleted"}) +} diff --git a/pkg/mining/xmrig_start.go b/pkg/mining/xmrig_start.go index 2f6993b..40e9fb2 100644 --- a/pkg/mining/xmrig_start.go +++ b/pkg/mining/xmrig_start.go @@ -49,7 +49,7 @@ func (m *XMRigMiner) Start(config *Config) error { } } - args := []string{"-c", m.ConfigPath} + args := []string{"-c", "\"" + m.ConfigPath + "\""} if m.API != nil && m.API.Enabled { args = append(args, "--http-host", m.API.ListenHost, "--http-port", fmt.Sprintf("%d", m.API.ListenPort)) diff --git a/ui/src/app/admin.component.css b/ui/src/app/admin.component.css index 525e947..6dbf71a 100644 --- a/ui/src/app/admin.component.css +++ b/ui/src/app/admin.component.css @@ -47,6 +47,7 @@ font-family: monospace; border-radius: 0.25rem; border: 1px solid #e0e0e0; + font-size: 0.749rem; /* Smaller font for easier selection */ } .path-list li { diff --git a/ui/src/app/admin.component.html b/ui/src/app/admin.component.html index f6e2ed7..34b3e73 100644 --- a/ui/src/app/admin.component.html +++ b/ui/src/app/admin.component.html @@ -1,97 +1,63 @@ - -@if (needsSetup) { -
-

To begin, please install a miner from the list below.

-

Available Miners

-
- @for (miner of manageableMiners; track miner.name) { -
- {{ miner.name }} +
+ @if (error()) { + +
+ + An Error Occurred +
+

{{ error() }}

+
+ } + +

Manage Miners

+
+ @for (miner of state().manageableMiners; track miner.name) { +
+ {{ miner.name }} + @if (miner.is_installed) { + + @if (actionInProgress() === 'uninstall-' + miner.name) { + + } @else { + + Uninstall + } + + } @else { - @if (actionInProgress === 'install-' + miner.name) { + @if (actionInProgress() === 'install-' + miner.name) { } @else { Install } -
- } @empty { -
- Could not load available miners. -
- } -
-
-} - - -@if (!needsSetup) { -
- @if (error) { - -
- - An Error Occurred -
-

{{ error }}

-
- } - -

Manage Miners

-
- @for (miner of manageableMiners; track miner.name) { -
- {{ miner.name }} - @if (miner.is_installed) { - - @if (actionInProgress === 'uninstall-' + miner.name) { - - } @else { - - Uninstall - } - - } @else { - - @if (actionInProgress === 'install-' + miner.name) { - - } @else { - - Install - } - - } -
- } @empty { -
- Could not load available miners. -
- } -
- -

Antivirus Whitelist Paths

-
-

To prevent antivirus software from interfering, please add the following paths to your exclusion list:

-
    - @for (path of whitelistPaths; track path) { -
  • {{ path }}
  • - } @empty { -
  • No paths to display. Install a miner to see required paths.
  • } -
-
+
+ } @empty { +
+ Could not load available miners. +
+ }
-} + +

Antivirus Whitelist Paths

+
+

To prevent antivirus software from interfering, please add the following paths to your exclusion list:

+
    + @for (path of whitelistPaths(); track path) { +
  • {{ path }}
  • + } @empty { +
  • No paths to display. Install a miner to see required paths.
  • + } +
+
+
diff --git a/ui/src/app/admin.component.ts b/ui/src/app/admin.component.ts index 6f064ff..1a5b52d 100644 --- a/ui/src/app/admin.component.ts +++ b/ui/src/app/admin.component.ts @@ -1,130 +1,72 @@ -import { Component, OnInit, Input, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { HttpClient, HttpClientModule, HttpErrorResponse } from '@angular/common/http'; +import { Component, CUSTOM_ELEMENTS_SCHEMA, inject, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { of, forkJoin } from 'rxjs'; -import { map, catchError } from 'rxjs/operators'; +import { HttpErrorResponse } from '@angular/common/http'; +import { MinerService } from './miner.service'; -// Define interfaces for our data structures -interface InstallationDetails { - is_installed: boolean; - version: string; - path: string; - miner_binary: string; - config_path?: string; - type?: string; -} - -interface AvailableMiner { - name: string; - description: string; -} +// Import Web Awesome components +import "@awesome.me/webawesome/dist/webawesome.js"; +import '@awesome.me/webawesome/dist/components/button/button.js'; +import '@awesome.me/webawesome/dist/components/spinner/spinner.js'; +import '@awesome.me/webawesome/dist/components/card/card.js'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; @Component({ selector: 'snider-mining-admin', standalone: true, schemas: [CUSTOM_ELEMENTS_SCHEMA], - imports: [CommonModule, HttpClientModule], + imports: [CommonModule], templateUrl: './admin.component.html', styleUrls: ['./admin.component.css'], }) -export class MiningAdminComponent implements OnInit { - @Input() needsSetup: boolean = false; // Input to trigger setup mode +export class MiningAdminComponent { + minerService = inject(MinerService); + state = this.minerService.state; + actionInProgress = signal(null); + error = signal(null); - apiBaseUrl: string = 'http://localhost:9090/api/v1/mining'; - - error: string | null = null; - actionInProgress: string | null = null; - manageableMiners: any[] = []; - whitelistPaths: string[] = []; - - constructor(private http: HttpClient) {} - - ngOnInit(): void { - this.getAdminData(); - } - - private handleError(err: HttpErrorResponse, defaultMessage: string) { - console.error(err); - this.actionInProgress = null; - if (err.error && err.error.error) { - this.error = `${defaultMessage}: ${err.error.error}`; - } else { - this.error = `${defaultMessage}. Please check the console for details.`; - } - } - - getAdminData(): void { - this.error = null; - forkJoin({ - available: this.http.get(`${this.apiBaseUrl}/miners/available`), - info: this.http.get(`${this.apiBaseUrl}/info`).pipe(catchError(() => of({}))) // Gracefully handle info error - }).pipe( - map(({ available, info }) => { - const installedMap = new Map( - (info.installed_miners_info || []).map((m: InstallationDetails) => [this.getMinerType(m), m]) - ); - - this.manageableMiners = available.map(availMiner => ({ - ...availMiner, - is_installed: installedMap.get(availMiner.name)?.is_installed ?? false, - })); - - const installedMiners = (info.installed_miners_info || []).filter((m: InstallationDetails) => m.is_installed); - this.updateWhitelistPaths(installedMiners, []); - }), - catchError(err => { - this.handleError(err, 'Could not load miner information'); - return of(null); - }) - ).subscribe(); - } - - private updateWhitelistPaths(installed: InstallationDetails[], running: any[]) { + whitelistPaths = computed(() => { const paths = new Set(); - installed.forEach(miner => { + this.state().installedMiners.forEach(miner => { if (miner.miner_binary) paths.add(miner.miner_binary); if (miner.config_path) paths.add(miner.config_path); }); - running.forEach(miner => { - if (miner.configPath) paths.add(miner.configPath); + this.state().runningMiners.forEach(miner => { + if ((miner as any).configPath) paths.add((miner as any).configPath); }); - this.whitelistPaths = Array.from(paths); - } + return Array.from(paths); + }); installMiner(minerType: string): void { - this.actionInProgress = `install-${minerType}`; - this.error = null; - this.http.post(`${this.apiBaseUrl}/miners/${minerType}/install`, {}).subscribe({ - next: () => { - setTimeout(() => { - this.getAdminData(); - // A simple way to signal completion is to reload the page - // so the main dashboard component re-evaluates its state. - if (this.needsSetup) { - window.location.reload(); - } - }, 1000); - }, - error: (err: HttpErrorResponse) => this.handleError(err, `Failed to install ${minerType}`) + this.actionInProgress.set(`install-${minerType}`); + this.error.set(null); + this.minerService.installMiner(minerType).subscribe({ + next: () => { this.actionInProgress.set(null); }, + error: (err: HttpErrorResponse) => { + this.handleError(err, `Failed to install ${minerType}`); + } }); } uninstallMiner(minerType: string): void { - this.actionInProgress = `uninstall-${minerType}`; - this.error = null; - this.http.delete(`${this.apiBaseUrl}/miners/${minerType}/uninstall`).subscribe({ - next: () => { - setTimeout(() => { - this.getAdminData(); - }, 1000); - }, - error: (err: HttpErrorResponse) => this.handleError(err, `Failed to uninstall ${minerType}`) + this.actionInProgress.set(`uninstall-${minerType}`); + this.error.set(null); + this.minerService.uninstallMiner(minerType).subscribe({ + next: () => { this.actionInProgress.set(null); }, + error: (err: HttpErrorResponse) => { + this.handleError(err, `Failed to uninstall ${minerType}`); + } }); } - getMinerType(miner: any): string { - if (!miner.path) return 'unknown'; - const parts = miner.path.split('/').filter((p: string) => p); - return parts.length > 1 ? parts[parts.length - 2] : parts[parts.length - 1] || 'unknown'; + private handleError(err: HttpErrorResponse, defaultMessage: string) { + console.error(err); + this.actionInProgress.set(null); + if (err.error && err.error.error) { + this.error.set(`${defaultMessage}: ${err.error.error}`); + } else if (typeof err.error === 'string' && err.error.length < 200) { + this.error.set(`${defaultMessage}: ${err.error}`); + } else { + this.error.set(`${defaultMessage}. Please check the console for details.`); + } } } diff --git a/ui/src/app/app.config.ts b/ui/src/app/app.config.ts index 4f117cd..44c74ba 100644 --- a/ui/src/app/app.config.ts +++ b/ui/src/app/app.config.ts @@ -1,14 +1,15 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; import { provideHighcharts } from 'highcharts-angular'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ - provideBrowserGlobalErrorListeners(), provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), + provideHttpClient(), provideHighcharts({ // Optional: Define the Highcharts instance dynamically instance: () => import('highcharts'), diff --git a/ui/src/app/app.html b/ui/src/app/app.html index e654c25..2605a78 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -1,31 +1,10 @@
- - - -
-
- - - @if (needsSetup) { - Setup Required - } @else { - Mining Control - } -
- @if (!needsSetup) { - - - - } -
- - - @if (systemInfo === null && !needsSetup) { + @if (state().systemInfo === null && !state().needsSetup) {
+

Connecting to API...

- } @else if (!apiAvailable) { - + } @else if (!state().apiAvailable) {

API Not Available. Please ensure the mining service is running.

@@ -33,196 +12,9 @@ Retry
- } @else if (apiAvailable && needsSetup) { - -
-

To begin, please install a miner from the list below.

-

Available Miners

-
- @for (miner of manageableMiners; track miner.name) { -
- {{ miner.name }} - - @if (actionInProgress === 'install-' + miner.name) { - - } @else { - - Install - } - -
- } @empty { -
- Could not load available miners. -
- } -
-
- } @else if (apiAvailable && !needsSetup) { - - @if (error) { - -
- - An Error Occurred -
-

{{ error }}

-
- } - - - @if (showAdminPanel) { -
-

Admin Panel

- -

Manage Miners

-
- @for (miner of manageableMiners; track miner.name) { -
- {{ miner.name }} - @if (miner.is_installed) { - - @if (actionInProgress === 'uninstall-' + miner.name) { - - } @else { - - Uninstall - } - - } @else { - - @if (actionInProgress === 'install-' + miner.name) { - - } @else { - - Install - } - - } -
- } @empty { -
- Could not load available miners. -
- } -
- -

Antivirus Whitelist Paths

-
-

To prevent antivirus software from interfering, please add the following paths to your exclusion list:

-
    - @for (path of whitelistPaths; track path) { -
  • {{ path }}
  • - } @empty { -
  • No paths to display. Install a miner to see required paths.
  • - } -
-
-
- } @else { - - @if (installedMiners.length > 0) { -
- @for (miner of installedMiners; track miner.path) { -
- - {{ miner.type }} - -
Version: {{ miner.version }}
- -
-
- - @if (isMinerRunning(miner)) { - - @if (actionInProgress === 'stop-' + miner.type) { - - } @else { - - Stop - } - - } @else { -
- - @if (actionInProgress === 'start-' + miner.type && showStartOptionsFor !== miner.type) { - - } @else { - - Start Last Config - } - - - - New Config - -
- } -
- - @if (showStartOptionsFor === miner.type) { -
- - - - @if (actionInProgress === 'start-' + miner.type) { - - } @else { - - Confirm & Start - } - -
- } - } -
- - -
- @for (miner of installedMiners; track miner.path) { - @if (isMinerRunning(miner) && chartOptionsMap.has(getRunningMinerInstance(miner).name)) { -
- -
- } - } -
- } @else { -
-

No miners installed. Open the Admin Panel to install one.

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

Loading chart...

+} diff --git a/ui/src/app/chart.component.ts b/ui/src/app/chart.component.ts new file mode 100644 index 0000000..1ea063c --- /dev/null +++ b/ui/src/app/chart.component.ts @@ -0,0 +1,135 @@ +import { Component, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, inject, effect, signal, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { HighchartsChartComponent, ChartConstructorType } from 'highcharts-angular'; +import * as Highcharts from 'highcharts'; +import { MinerService } from './miner.service'; + +// More specific type for series with data +type SeriesWithData = Highcharts.SeriesAreaOptions | Highcharts.SeriesSplineOptions; + +@Component({ + selector: 'snider-mining-chart', + standalone: true, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule, HighchartsChartComponent], + templateUrl: './chart.component.html', + styleUrls: ['./chart.component.css'] +}) +export class ChartComponent { + @Input() minerName?: string; + minerService = inject(MinerService); + + Highcharts: typeof Highcharts = Highcharts; + chartConstructor: ChartConstructorType = 'chart'; + chartOptions = signal({}); + updateFlag = signal(false); + + constructor() { + this.chartOptions.set(this.createBaseChartOptions()); + + effect(() => { + const historyMap = this.minerService.hashrateHistory(); + let yAxisOptions: Highcharts.YAxisOptions = {}; + + if (this.minerName) { + // Single miner mode + const history = historyMap.get(this.minerName); + const chartData = history ? history.map(point => [new Date(point.timestamp).getTime(), point.hashrate]) : []; + + yAxisOptions = this.calculateYAxisBoundsForSingle(chartData.map(d => d[1])); + + this.chartOptions.update(options => ({ + ...options, + title: { text: `${this.minerName} Hashrate` }, + chart: { type: 'spline' }, + plotOptions: { area: undefined, spline: { marker: { enabled: false } } }, + yAxis: { ...options.yAxis, ...yAxisOptions }, + series: [{ type: 'spline', name: 'Hashrate', data: chartData }] + })); + + } else { + // Overview mode + if (historyMap.size === 0) { + this.chartOptions.update(options => ({ ...options, series: [] })); + } else { + const newSeries: SeriesWithData[] = []; + historyMap.forEach((history, name) => { + const chartData = history.map(point => [new Date(point.timestamp).getTime(), point.hashrate]); + newSeries.push({ type: 'area', name: name, data: chartData }); + }); + + yAxisOptions = this.calculateYAxisBoundsForStacked(newSeries); + + this.chartOptions.update(options => ({ + ...options, + title: { text: 'Total Hashrate' }, + chart: { type: 'area' }, + plotOptions: { area: { stacking: 'normal', marker: { enabled: false } } }, + yAxis: { ...options.yAxis, ...yAxisOptions }, + series: newSeries + })); + } + } + + this.updateFlag.update(flag => !flag); + }); + } + + private calculateYAxisBoundsForSingle(data: number[]): Highcharts.YAxisOptions { + if (data.length === 0) { + return { min: 0, max: undefined }; + } + + const min = Math.min(...data); + const max = Math.max(...data); + + if (min === max) { + return { min: Math.max(0, min - 50), max: max + 50 }; + } + + const padding = (max - min) * 0.1; // 10% padding + + return { + min: Math.max(0, min - padding), + max: max + padding + }; + } + + private calculateYAxisBoundsForStacked(series: SeriesWithData[]): Highcharts.YAxisOptions { + const totalsByTimestamp: { [key: number]: number } = {}; + + series.forEach(s => { + // Cast to any to avoid TS errors with union types where 'data' might be missing on some types + // even though we know SeriesWithData has it. + const data = (s as any).data; + if (data) { + (data as [number, number][]).forEach(([timestamp, value]) => { + totalsByTimestamp[timestamp] = (totalsByTimestamp[timestamp] || 0) + value; + }); + } + }); + + const totalValues = Object.values(totalsByTimestamp); + if (totalValues.length === 0) { + return { min: 0, max: undefined }; + } + + const maxTotal = Math.max(...totalValues); + const padding = maxTotal * 0.1; // 10% padding on top + + return { + min: 0, // Stacked chart should always start at 0 + max: maxTotal + padding + }; + } + + createBaseChartOptions(): Highcharts.Options { + return { + xAxis: { type: 'datetime', title: { text: 'Time' } }, + yAxis: { title: { text: 'Hashrate (H/s)' } }, // Remove min: 0 to allow dynamic scaling + series: [], + credits: { enabled: false }, + accessibility: { enabled: false } + }; + } +} diff --git a/ui/src/app/dashboard.component.css b/ui/src/app/dashboard.component.css new file mode 100644 index 0000000..76a0616 --- /dev/null +++ b/ui/src/app/dashboard.component.css @@ -0,0 +1,94 @@ +.admin-panel { + padding: 1rem; +} + +.admin-title { + margin-top: 0; +} + +.miner-list, .path-list { + margin-bottom: 1.5rem; +} + +.miner-item, .path-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + margin-bottom: 0.5rem; +} + +.path-list ul { + list-style-type: none; + padding: 0; +} + +.path-list li { + background-color: #f5f5f5; + padding: 0.5rem; + border-radius: 4px; + margin-bottom: 0.5rem; + font-family: monospace; +} + +.dashboard-summary { + padding: 1rem; +} + +.miner-summary-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.miner-name { + font-weight: bold; +} + +.start-buttons { + display: flex; + gap: 0.5rem; +} + +.start-options { + padding: 1rem; + border: 1px solid #ccc; + border-radius: 4px; + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.dashboard-charts { + padding: 1rem; +} + +.miner-chart-item { + margin-bottom: 1.5rem; +} + +.centered-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; +} + +.button-spinner { + font-size: 1rem; +} + +.card-error { + margin-bottom: 1rem; + --wa-card-border-color: var(--wa-color-danger-border); +} + +.card-error [slot="header"] { + color: var(--wa-color-danger-text); +} diff --git a/ui/src/app/dashboard.component.html b/ui/src/app/dashboard.component.html new file mode 100644 index 0000000..67a3c43 --- /dev/null +++ b/ui/src/app/dashboard.component.html @@ -0,0 +1,87 @@ +
+
+ + Mining Control + + + +
+ + @if (error()) { + +
+ + An Error Occurred +
+

{{ error() }}

+
+ } + + @if(showProfileManager()) { +
+ + +
+ } @else { + + @if (state().installedMiners.length > 0) { +
+ @for (miner of state().installedMiners; track miner.path) { +
+ + {{ miner.type }} + +
Version: {{ miner.version }}
+ +
+
+ + @if (isMinerRunning(miner)) { + + @if (actionInProgress() === 'stop-' + miner.type) { + + } @else { + + Stop + } + + } @else { +
+ + Profile + @for(profile of state().profiles; track profile.id) { + @if(profile.minerType === miner.type) { + {{ profile.name }} + } + } + + + + Start + +
+ } +
+ } +
+ + + @if (state().runningMiners.length > 0) { +
+ +
+ } + + } @else { +
+

No miners installed. Open the Admin Panel to install one.

+
+ } + } +
diff --git a/ui/src/app/dashboard.component.ts b/ui/src/app/dashboard.component.ts new file mode 100644 index 0000000..6ef9019 --- /dev/null +++ b/ui/src/app/dashboard.component.ts @@ -0,0 +1,98 @@ +import { Component, ViewEncapsulation, CUSTOM_ELEMENTS_SCHEMA, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; +import { MinerService } from './miner.service'; +import { ChartComponent } from './chart.component'; +import { ProfileListComponent } from './profile-list.component'; +import { ProfileCreateComponent } from './profile-create.component'; + +// Import Web Awesome components +import "@awesome.me/webawesome/dist/webawesome.js"; +import '@awesome.me/webawesome/dist/components/card/card.js'; +import '@awesome.me/webawesome/dist/components/button/button.js'; +import '@awesome.me/webawesome/dist/components/tooltip/tooltip.js'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import '@awesome.me/webawesome/dist/components/spinner/spinner.js'; +import '@awesome.me/webawesome/dist/components/input/input.js'; +import '@awesome.me/webawesome/dist/components/select/select.js'; + +@Component({ + selector: 'snider-mining-dashboard', + standalone: true, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [CommonModule, FormsModule, ChartComponent, ProfileListComponent, ProfileCreateComponent], + templateUrl: './dashboard.component.html', + styleUrls: ['./dashboard.component.css'] +}) +export class MiningDashboardComponent { + minerService = inject(MinerService); + state = this.minerService.state; + + actionInProgress = signal(null); + error = signal(null); + + showProfileManager = signal(false); + selectedProfileId = signal(null); + + handleProfileSelection(event: any) { + // The value is in the detail property of the custom event + this.selectedProfileId.set(event.detail.value); + } + + private handleError(err: HttpErrorResponse, defaultMessage: string) { + console.error(err); + this.actionInProgress.set(null); + if (err.error && err.error.error) { + this.error.set(`${defaultMessage}: ${err.error.error}`); + } else if (typeof err.error === 'string' && err.error.length < 200) { + this.error.set(`${defaultMessage}: ${err.error}`); + } else { + this.error.set(`${defaultMessage}. Please check the console for details.`); + } + } + + startMiner(): void { + const profileId = this.selectedProfileId(); + if (!profileId) { + this.error.set('Please select a profile to start.'); + return; + } + this.actionInProgress.set(`start-${profileId}`); + this.error.set(null); + this.minerService.startMiner(profileId).subscribe({ + next: () => { this.actionInProgress.set(null); }, + error: (err: HttpErrorResponse) => { + this.handleError(err, `Failed to start miner for profile ${profileId}`); + } + }); + } + + stopMiner(miner: any): void { + const runningInstance = this.getRunningMinerInstance(miner); + if (!runningInstance) { + this.error.set("Cannot stop a miner that is not running."); + return; + } + this.actionInProgress.set(`stop-${miner.type}`); + this.error.set(null); + this.minerService.stopMiner(runningInstance.name).subscribe({ + next: () => { this.actionInProgress.set(null); }, + error: (err: HttpErrorResponse) => { + this.handleError(err, `Failed to stop ${runningInstance.name}`); + } + }); + } + + getRunningMinerInstance(miner: any): any { + return this.state().runningMiners.find((m: any) => m.name.startsWith(miner.type)); + } + + isMinerRunning(miner: any): boolean { + return !!this.getRunningMinerInstance(miner); + } + + toggleProfileManager() { + this.showProfileManager.set(!this.showProfileManager()); + } +} diff --git a/ui/src/app/miner.service.ts b/ui/src/app/miner.service.ts new file mode 100644 index 0000000..e3d777e --- /dev/null +++ b/ui/src/app/miner.service.ts @@ -0,0 +1,241 @@ +import { Injectable, OnDestroy, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { of, forkJoin, Subscription, interval } from 'rxjs'; +import { switchMap, catchError, map, startWith, tap } from 'rxjs/operators'; + +// Define interfaces +export interface InstallationDetails { + is_installed: boolean; + version: string; + path: string; + miner_binary: string; + config_path?: string; + type?: string; +} + +export interface AvailableMiner { + name: string; + description: string; +} + +export interface HashratePoint { + timestamp: string; + hashrate: number; +} + +export interface MiningProfile { + id: string; + name: string; + minerType: string; + config: any; +} + +export interface SystemState { + needsSetup: boolean; + apiAvailable: boolean; + error: string | null; + systemInfo: any; + manageableMiners: any[]; + installedMiners: InstallationDetails[]; + runningMiners: any[]; + profiles: MiningProfile[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class MinerService implements OnDestroy { + private apiBaseUrl = 'http://localhost:9090/api/v1/mining'; + + // State Signals + public state = signal({ + needsSetup: false, + apiAvailable: true, + error: null, + systemInfo: {}, + manageableMiners: [], + installedMiners: [], + runningMiners: [], + profiles: [] + }); + + public hashrateHistory = signal>(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(); + + (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(); + 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(`${this.apiBaseUrl}/miners/available`); + } + + getSystemInfo() { + return this.http.get(`${this.apiBaseUrl}/info`); + } + + getRunningMiners() { + return this.http.get(`${this.apiBaseUrl}/miners`); + } + + getMinerHashrateHistory(minerName: string) { + return this.http.get(`${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(`${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'; + } +} diff --git a/ui/src/app/profile-create.component.css b/ui/src/app/profile-create.component.css new file mode 100644 index 0000000..6f3ab7f --- /dev/null +++ b/ui/src/app/profile-create.component.css @@ -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; +} diff --git a/ui/src/app/profile-create.component.html b/ui/src/app/profile-create.component.html new file mode 100644 index 0000000..a5bea0c --- /dev/null +++ b/ui/src/app/profile-create.component.html @@ -0,0 +1,39 @@ + +
+
Create New Profile
+ + + @if (error) { + +

{{ error }}

+
+ } + @if (success) { + +

{{ success }}

+
+ } + + + + + + @for(miner of state().manageableMiners; track miner.name) { + {{ miner.name }} + } + + + +
+ Configuration + + + TLS + Huge Pages +
+ + Create Profile +
diff --git a/ui/src/app/profile-create.component.ts b/ui/src/app/profile-create.component.ts new file mode 100644 index 0000000..7473b98 --- /dev/null +++ b/ui/src/app/profile-create.component.ts @@ -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.'; + } + } + }); + } +} diff --git a/ui/src/app/profile-list.component.css b/ui/src/app/profile-list.component.css new file mode 100644 index 0000000..2998870 --- /dev/null +++ b/ui/src/app/profile-list.component.css @@ -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%; +} diff --git a/ui/src/app/profile-list.component.html b/ui/src/app/profile-list.component.html new file mode 100644 index 0000000..9976643 --- /dev/null +++ b/ui/src/app/profile-list.component.html @@ -0,0 +1,31 @@ +
+
Existing Profiles
+ @for(profile of state().profiles; track profile.id) { +
+ @if(editingProfile && editingProfile.id === profile.id) { +
+ + + @for(miner of state().manageableMiners; track miner.name) { + {{ miner.name }} + } + + + + TLS + Huge Pages + Save + Cancel +
+ } @else { + {{ profile.name }} ({{ profile.minerType }}) +
+ Edit + Delete +
+ } +
+ } @empty { +

No profiles created yet.

+ } +
diff --git a/ui/src/app/profile-list.component.ts b/ui/src/app/profile-list.component.ts new file mode 100644 index 0000000..608c668 --- /dev/null +++ b/ui/src/app/profile-list.component.ts @@ -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; + } +} diff --git a/ui/src/app/setup-wizard.component.css b/ui/src/app/setup-wizard.component.css new file mode 100644 index 0000000..6fd2bdd --- /dev/null +++ b/ui/src/app/setup-wizard.component.css @@ -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; +} diff --git a/ui/src/app/setup-wizard.component.html b/ui/src/app/setup-wizard.component.html new file mode 100644 index 0000000..fb51a9b --- /dev/null +++ b/ui/src/app/setup-wizard.component.html @@ -0,0 +1,56 @@ +
+
+ + Setup Required +
+ +

To begin, please install a miner from the list below.

+

Available Miners

+
+ @for (miner of state().manageableMiners; track miner.name) { +
+ {{ miner.name }} + @if (miner.is_installed) { + + @if (actionInProgress() === 'uninstall-' + miner.name) { + + } @else { + + Uninstall + } + + } @else { + + @if (actionInProgress() === 'install-' + miner.name) { + + } @else { + + Install + } + + } +
+ } @empty { +
+ Could not load available miners. +
+ } +
+ @if (error()) { + +
+ + An Error Occurred +
+

{{ error() }}

+
+ } +
diff --git a/ui/src/app/setup-wizard.component.ts b/ui/src/app/setup-wizard.component.ts new file mode 100644 index 0000000..81ecf62 --- /dev/null +++ b/ui/src/app/setup-wizard.component.ts @@ -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(null); + error = signal(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.`); + } + } +} diff --git a/ui/src/app/uuid.d.ts b/ui/src/app/uuid.d.ts new file mode 100644 index 0000000..f9d6a6d --- /dev/null +++ b/ui/src/app/uuid.d.ts @@ -0,0 +1 @@ +declare module 'uuid'; diff --git a/ui/src/index.html b/ui/src/index.html index d8ff6c8..a57e118 100644 --- a/ui/src/index.html +++ b/ui/src/index.html @@ -1,14 +1,54 @@ - + - Ui + Snider Mining + - - - + +
+ +
+
+

Mining Dashboard (Dev View)

+
+
+

User Info

+
+
+ + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ + + +
diff --git a/ui/src/main.ts b/ui/src/main.ts index 329ca46..4ff3a90 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -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.'); })();