feat: Add stdin console commands, SQLite persistence, and P2P enhancements

- Add stdin pipe support for sending console commands to running miners (XMRig/TT-Miner)
- Add base64 encoding for log transport to preserve ANSI escape codes
- Add SQLite database for persistent hashrate history storage
- Enhance P2P worker to handle remote miner commands (start/stop/stats/logs)
- Add console UI page with ANSI-to-HTML rendering and command input
- Add E2E tests for navigation, UI elements, and miner start flow
- Update Dockerfile to use Go 1.24 with GOTOOLCHAIN=auto

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
snider 2025-12-29 23:30:19 +00:00
parent e0c9c92244
commit f10e7a16e2
47 changed files with 727 additions and 1278 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -92,6 +92,7 @@ type BaseMiner struct {
API *API `json:"api"`
mu sync.RWMutex
cmd *exec.Cmd
stdinPipe io.WriteCloser `json:"-"`
HashrateHistory []HashratePoint `json:"hashrateHistory"`
LowResHashrateHistory []HashratePoint `json:"lowResHashrateHistory"`
LastLowResAggregation time.Time `json:"-"`
@ -135,9 +136,33 @@ func (b *BaseMiner) Stop() error {
return errors.New("miner is not running")
}
// Close stdin pipe if open
if b.stdinPipe != nil {
b.stdinPipe.Close()
b.stdinPipe = nil
}
return b.cmd.Process.Kill()
}
// WriteStdin sends input to the miner's stdin (for console commands).
func (b *BaseMiner) WriteStdin(input string) error {
b.mu.RLock()
defer b.mu.RUnlock()
if !b.Running || b.stdinPipe == nil {
return errors.New("miner is not running or stdin not available")
}
// Append newline if not present
if !strings.HasSuffix(input, "\n") {
input += "\n"
}
_, err := b.stdinPipe.Write([]byte(input))
return err
}
// Uninstall removes all files related to the miner.
func (b *BaseMiner) Uninstall() error {
return os.RemoveAll(b.GetPath())

View file

@ -27,6 +27,7 @@ type Miner interface {
AddHashratePoint(point HashratePoint)
ReduceHashrateHistory(now time.Time)
GetLogs() []string
WriteStdin(input string) error
}
// InstallationDetails contains information about an installed miner.

View file

@ -2,6 +2,7 @@ package mining
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
@ -129,6 +130,7 @@ func (s *Service) SetupRoutes() {
minersGroup.GET("/:miner_name/stats", s.handleGetMinerStats)
minersGroup.GET("/:miner_name/hashrate-history", s.handleGetMinerHashrateHistory)
minersGroup.GET("/:miner_name/logs", s.handleGetMinerLogs)
minersGroup.POST("/:miner_name/stdin", s.handleMinerStdin)
}
// Historical data endpoints (database-backed)
@ -471,11 +473,11 @@ func (s *Service) handleGetMinerHashrateHistory(c *gin.Context) {
// handleGetMinerLogs godoc
// @Summary Get miner log output
// @Description Get the captured stdout/stderr output from a running miner
// @Description Get the captured stdout/stderr output from a running miner. Log lines are base64 encoded to preserve ANSI escape codes and special characters.
// @Tags miners
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Success 200 {array} string
// @Success 200 {array} string "Base64 encoded log lines"
// @Router /miners/{miner_name}/logs [get]
func (s *Service) handleGetMinerLogs(c *gin.Context) {
minerName := c.Param("miner_name")
@ -485,7 +487,51 @@ func (s *Service) handleGetMinerLogs(c *gin.Context) {
return
}
logs := miner.GetLogs()
c.JSON(http.StatusOK, logs)
// Base64 encode each log line to preserve ANSI escape codes and special characters
encodedLogs := make([]string, len(logs))
for i, line := range logs {
encodedLogs[i] = base64.StdEncoding.EncodeToString([]byte(line))
}
c.JSON(http.StatusOK, encodedLogs)
}
// StdinInput represents input to send to miner's stdin
type StdinInput struct {
Input string `json:"input" binding:"required"`
}
// handleMinerStdin godoc
// @Summary Send input to miner stdin
// @Description Send console commands to a running miner's stdin (e.g., 'h' for hashrate, 'p' for pause)
// @Tags miners
// @Accept json
// @Produce json
// @Param miner_name path string true "Miner Name"
// @Param input body StdinInput true "Input to send"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /miners/{miner_name}/stdin [post]
func (s *Service) handleMinerStdin(c *gin.Context) {
minerName := c.Param("miner_name")
miner, err := s.Manager.GetMiner(minerName)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "miner not found"})
return
}
var input StdinInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()})
return
}
if err := miner.WriteStdin(input.Input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent", "input": input.Input})
}
// handleListProfiles godoc

View file

@ -39,6 +39,13 @@ func (m *TTMiner) Start(config *Config) error {
m.cmd = exec.Command(m.MinerBinary, args...)
// Create stdin pipe for console commands
stdinPipe, err := m.cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
m.stdinPipe = stdinPipe
// Always capture output to LogBuffer
if m.LogBuffer != nil {
m.cmd.Stdout = m.LogBuffer

View file

@ -62,6 +62,13 @@ func (m *XMRigMiner) Start(config *Config) error {
m.cmd = exec.Command(m.MinerBinary, args...)
// Create stdin pipe for console commands
stdinPipe, err := m.cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
m.stdinPipe = stdinPipe
// Always capture output to LogBuffer
if m.LogBuffer != nil {
m.cmd.Stdout = m.LogBuffer

View file

@ -0,0 +1,251 @@
import { test, expect } from '@playwright/test';
import { MainLayoutPage } from '../page-objects/main-layout.page';
import { ConsolePage } from '../page-objects/console.page';
test.describe('Console Page', () => {
let layout: MainLayoutPage;
let consolePage: ConsolePage;
test.beforeEach(async ({ page }) => {
layout = new MainLayoutPage(page);
consolePage = new ConsolePage(page);
await layout.goto();
await layout.waitForLayoutLoad();
await layout.navigateToConsole();
});
test.describe('Console Layout', () => {
test('should display console page container', async () => {
await expect(consolePage.consolePage).toBeVisible();
});
test('should display console header', async () => {
await expect(consolePage.tabsContainer).toBeVisible();
});
test('should display console output area', async () => {
await expect(consolePage.consoleOutput).toBeVisible();
});
test('should display console controls', async () => {
await expect(consolePage.autoScrollCheckbox).toBeVisible();
await expect(consolePage.clearButton).toBeVisible();
});
});
test.describe('Worker Selection', () => {
test('should show worker dropdown when miners are running', async ({ page }) => {
// Check if there's a worker select dropdown or no miners message
const workerSelect = page.locator('.worker-select');
const noMinersMsg = page.locator('.no-miners-msg');
// Either worker select or no miners message should be visible
const hasWorkerSelect = await workerSelect.isVisible();
const hasNoMiners = await noMinersMsg.isVisible();
expect(hasWorkerSelect || hasNoMiners).toBe(true);
});
test('should auto-select first miner on load', async ({ page }) => {
const workerSelect = page.locator('.worker-select');
if (await workerSelect.isVisible()) {
// A miner should be selected (value should not be empty)
const selectedValue = await workerSelect.inputValue();
expect(selectedValue).toBeTruthy();
}
});
test('should display miner tabs when multiple miners running', async ({ page }) => {
// This test checks if tabs appear when there are multiple miners
// We just verify the tabs container exists in the header
const consoleTabs = page.locator('.console-tabs');
// Tabs only show when multiple miners, so this may or may not be visible
// Just ensure no errors
await consolePage.tabsContainer.isVisible();
});
});
test.describe('Console Output', () => {
test('should display logs when miner is running', async ({ page }) => {
const workerSelect = page.locator('.worker-select');
if (await workerSelect.isVisible()) {
// Wait for logs to load (poll happens every 2 seconds)
await page.waitForTimeout(3000);
// Check if logs are displayed or waiting message
const logLines = await consolePage.getLogLineCount();
const waitingMsg = page.getByText(/Waiting for logs from/);
const hasWaitingMsg = await waitingMsg.isVisible();
// Either logs should be present or waiting message
expect(logLines > 0 || hasWaitingMsg).toBe(true);
}
});
test('should show empty state when no miners running', async ({ page }) => {
const noMinersMsg = page.locator('.no-miners-msg');
if (await noMinersMsg.isVisible()) {
// When no miners, empty state should show
const emptyState = consolePage.emptyState;
await expect(emptyState).toBeVisible();
}
});
test('should style error lines correctly', async ({ page }) => {
// Wait for logs
await page.waitForTimeout(3000);
const errorLines = page.locator('.log-line.error');
const errorCount = await errorLines.count();
// If there are error lines, verify they have error styling
if (errorCount > 0) {
const firstError = errorLines.first();
await expect(firstError).toHaveClass(/error/);
}
});
test('should style warning lines correctly', async ({ page }) => {
// Wait for logs
await page.waitForTimeout(3000);
const warningLines = page.locator('.log-line.warning');
const warningCount = await warningLines.count();
// If there are warning lines, verify they have warning styling
if (warningCount > 0) {
const firstWarning = warningLines.first();
await expect(firstWarning).toHaveClass(/warning/);
}
});
});
test.describe('Console Controls', () => {
test('should have auto-scroll enabled by default', async () => {
const isEnabled = await consolePage.isAutoScrollEnabled();
expect(isEnabled).toBe(true);
});
test('should toggle auto-scroll when checkbox clicked', async () => {
// Initially enabled
expect(await consolePage.isAutoScrollEnabled()).toBe(true);
// Toggle off
await consolePage.toggleAutoScroll();
expect(await consolePage.isAutoScrollEnabled()).toBe(false);
// Toggle back on
await consolePage.toggleAutoScroll();
expect(await consolePage.isAutoScrollEnabled()).toBe(true);
});
test('should clear logs when clear button clicked', async ({ page }) => {
const workerSelect = page.locator('.worker-select');
if (await workerSelect.isVisible()) {
// Wait for logs to load
await page.waitForTimeout(3000);
const initialLogCount = await consolePage.getLogLineCount();
if (initialLogCount > 0) {
// Clear logs
await consolePage.clearLogs();
// Verify logs are cleared
const finalLogCount = await consolePage.getLogLineCount();
expect(finalLogCount).toBe(0);
}
}
});
test('should disable clear button when no logs', async ({ page }) => {
const workerSelect = page.locator('.worker-select');
if (await workerSelect.isVisible()) {
// Wait for logs
await page.waitForTimeout(3000);
const logCount = await consolePage.getLogLineCount();
if (logCount > 0) {
// Clear logs
await consolePage.clearLogs();
// Clear button should be disabled now
const isEnabled = await consolePage.isClearButtonEnabled();
expect(isEnabled).toBe(false);
}
}
});
});
test.describe('Log Polling', () => {
test('should update logs periodically', async ({ page }) => {
const workerSelect = page.locator('.worker-select');
if (await workerSelect.isVisible()) {
// Wait for initial logs
await page.waitForTimeout(3000);
const initialLogs = await consolePage.getLogContent();
// Wait for another poll cycle (2+ seconds)
await page.waitForTimeout(3000);
// Check if new logs appeared (miner generates output)
// We can't guarantee new logs, but verify no errors
const finalLogs = await consolePage.getLogContent();
// Logs array should still be valid
expect(Array.isArray(finalLogs)).toBe(true);
}
});
});
test.describe('Worker Switching', () => {
test('should switch miner and clear logs when selection changes', async ({ page }) => {
const workerSelect = page.locator('.worker-select');
if (await workerSelect.isVisible()) {
// Get available options
const options = await workerSelect.locator('option').allTextContents();
if (options.length > 1) {
// Wait for initial logs
await page.waitForTimeout(3000);
// Get initial selected value
const initialValue = await workerSelect.inputValue();
// Find a different miner to select
const otherMiner = options.find(opt => opt !== initialValue);
if (otherMiner) {
// Select different miner
await workerSelect.selectOption(otherMiner);
// Logs should be cleared momentarily (new miner selected)
// Then new logs should load
await page.waitForTimeout(500);
}
}
}
});
});
test.describe('Responsive Behavior', () => {
test('should maintain layout when resized', async ({ page }) => {
// Resize to smaller viewport
await page.setViewportSize({ width: 800, height: 600 });
// Console should still be visible and functional
await expect(consolePage.consolePage).toBeVisible();
await expect(consolePage.consoleOutput).toBeVisible();
await expect(consolePage.clearButton).toBeVisible();
});
});
});

View file

@ -1,78 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "Quick Test 1767041846199"
- option "Mining Test 1767041844401"
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- option "FT-cancel-1767041832358"
- option "Long Test 1767041847141"
- option "FT-start-1767041826205"
- option "FT-editform-1767041826397"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

View file

@ -1,80 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "FT-edit-1767041826330"
- option "FT-cancel-1767041832358"
- option "Long Test 1767041847141"
- option "E2E List Test Profile"
- option "FT-start-1767041826205"
- option "FT-editform-1767041826397"
- option "Quick Test 1767041846199"
- option "Mining Test 1767041844401"
- option "E2E Delete Test Profile"
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- option "FT-delete-1767041826329"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

View file

@ -1,21 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- generic [ref=e6]:
- img [ref=e7]
- text: Setup Required
- paragraph [ref=e9]: To begin, please install a miner from the list below.
- heading "Available Miners" [level=4] [ref=e10]
- generic [ref=e11]:
- generic [ref=e12]:
- text: xmrig
- button "Install" [ref=e13]:
- img [ref=e14]
- text: Install
- generic [ref=e16]:
- text: tt-miner
- button "Install" [ref=e17]:
- img [ref=e18]
- text: Install
```

View file

@ -1,78 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "FT-editform-1767041826397"
- option "Quick Test 1767041846199"
- option "Mining Test 1767041844401"
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- option "FT-cancel-1767041832358"
- option "Long Test 1767041847141"
- option "FT-start-1767041826205"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

View file

@ -1,75 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- option "FT-cancel-1767041832358"
- option "FT-start-1767041826205"
- option "FT-editform-1767041826397"
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

View file

@ -1,69 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "E2E Test 1767041854325"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View file

@ -1,74 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- option "FT-start-1767041826205"
- option "FT-editform-1767041826397"
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

View file

@ -1,70 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 8s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "1"
- generic [ref=e76]: Workers
- button "All Workers (1)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (1)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- generic [ref=e92]: "Worker:"
- combobox [ref=e93] [cursor=pointer]:
- option "xmrig-279" [selected]
- paragraph [ref=e96]: Waiting for logs from xmrig-279...
- generic [ref=e97]:
- generic [ref=e98] [cursor=pointer]:
- checkbox "Auto-scroll" [checked] [ref=e99]
- generic [ref=e100]: Auto-scroll
- button "Clear" [disabled] [ref=e101]:
- img
- text: Clear
```

View file

@ -1,73 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "FT-start-1767041826205"
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

View file

@ -1,74 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "FT-start-1767041826205"
- option "FT-editform-1767041826397"
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

View file

@ -1,68 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,114 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 1s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "2"
- generic [ref=e76]: Workers
- button "All Workers (2)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (2)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e90]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- option "FT-cancel-1767041832358"
- option "FT-start-1767041826205"
- option "FT-editform-1767041826397"
- option "Quick Test 1767041846199"
- option "Mining Test 1767041844401"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- button "Stop All" [ref=e95] [cursor=pointer]:
- img [ref=e96]
- text: Stop All
- table [ref=e100]:
- rowgroup [ref=e101]:
- row "Worker Hashrate Shares Efficiency Uptime Pool Actions" [ref=e102]:
- columnheader "Worker" [ref=e103]
- columnheader "Hashrate" [ref=e104]
- columnheader "Shares" [ref=e105]
- columnheader "Efficiency" [ref=e106]
- columnheader "Uptime" [ref=e107]
- columnheader "Pool" [ref=e108]
- columnheader "Actions" [ref=e109]
- rowgroup [ref=e110]:
- row "xmrig-607 0H/s 0 100.0% 1s N/A" [ref=e111]:
- cell "xmrig-607" [ref=e112]:
- generic [ref=e115]: xmrig-607
- cell "0H/s" [ref=e116]: 0H/s
- cell "0" [ref=e118]
- cell "100.0%" [ref=e119]
- cell "1s" [ref=e120]
- cell "N/A" [ref=e121]
- cell [ref=e122]:
- button "View logs" [ref=e123] [cursor=pointer]:
- img [ref=e124]
- button "Stop worker" [ref=e126] [cursor=pointer]:
- img [ref=e127]
- row "xmrig-51 0H/s 0 100.0% 0s N/A" [ref=e129]:
- cell "xmrig-51" [ref=e130]:
- generic [ref=e133]: xmrig-51
- cell "0H/s" [ref=e134]: 0H/s
- cell "0" [ref=e136]
- cell "100.0%" [ref=e137]
- cell "0s" [ref=e138]
- cell "N/A" [ref=e139]
- cell [ref=e140]:
- button "View logs" [ref=e141] [cursor=pointer]:
- img [ref=e142]
- button "Stop worker" [ref=e144] [cursor=pointer]:
- img [ref=e145]
```

View file

@ -1,80 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- option "FT-cancel-1767041832358"
- option "Long Test 1767041847141"
- option "E2E List Test Profile"
- option "FT-start-1767041826205"
- option "FT-editform-1767041826397"
- option "Quick Test 1767041846199"
- option "Mining Test 1767041844401"
- option "E2E Delete Test Profile"
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

View file

@ -1,75 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- option "FT-cancel-1767041832358"
- option "FT-start-1767041826205"
- option "FT-editform-1767041826397"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View file

@ -1,74 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "Mining Test 1767031630070"
- option "FT-display-1767041826192"
- option "FT-delete-1767041826329"
- option "FT-edit-1767041826330"
- option "FT-start-1767041826205"
- option "FT-editform-1767041826397"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

View file

@ -1,69 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e5]:
- complementary [ref=e7]:
- generic [ref=e8]:
- generic [ref=e9]:
- img
- generic [ref=e11]: Mining
- button [ref=e12] [cursor=pointer]:
- img [ref=e13]
- navigation [ref=e15]:
- button "Workers" [ref=e16] [cursor=pointer]:
- generic [ref=e18]: Workers
- button "Graphs" [ref=e19] [cursor=pointer]:
- generic [ref=e21]: Graphs
- button "Console" [ref=e22] [cursor=pointer]:
- generic [ref=e24]: Console
- button "Pools" [ref=e25] [cursor=pointer]:
- generic [ref=e27]: Pools
- button "Profiles" [ref=e28] [cursor=pointer]:
- generic [ref=e30]: Profiles
- button "Miners" [ref=e31] [cursor=pointer]:
- generic [ref=e33]: Miners
- generic [ref=e37]: Mining Active
- generic [ref=e38]:
- generic [ref=e39]:
- generic [ref=e41]:
- generic [ref=e42]:
- img [ref=e43]
- generic [ref=e45]:
- generic [ref=e46]: "0"
- generic [ref=e47]: H/s
- generic [ref=e48]: Hashrate
- generic [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: "0"
- generic [ref=e55]: Shares
- generic [ref=e57]:
- img [ref=e58]
- generic [ref=e61]: 0s
- generic [ref=e62]: Uptime
- generic [ref=e64]:
- img [ref=e65]
- generic [ref=e68]: Not connected
- generic [ref=e69]: Pool
- generic [ref=e71]:
- img [ref=e72]
- generic [ref=e75]: "0"
- generic [ref=e76]: Workers
- button "All Workers (0)" [ref=e79] [cursor=pointer]:
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e83]: All Workers
- generic [ref=e84]: (0)
- img [ref=e85]
- generic [ref=e89]:
- generic [ref=e91]:
- combobox [ref=e92]:
- option "Select profile..." [disabled] [selected]
- option "Mining Test 1767031630070"
- button "Start" [disabled] [ref=e93]:
- img
- text: Start
- generic [ref=e95]:
- img [ref=e96]
- heading "No Active Workers" [level=3] [ref=e98]
- paragraph [ref=e99]: Select a profile and start mining to see workers here.
```

File diff suppressed because one or more lines are too long

View file

@ -1,29 +1,24 @@
import { Routes } from '@angular/router';
import { MainLayoutComponent } from './layouts/main-layout.component';
import { WorkersComponent } from './pages/workers/workers.component';
import { GraphsComponent } from './pages/graphs/graphs.component';
import { ConsoleComponent } from './pages/console/console.component';
import { PoolsComponent } from './pages/pools/pools.component';
import { ProfilesComponent } from './pages/profiles/profiles.component';
import { MinersComponent } from './pages/miners/miners.component';
import { NodesComponent } from './pages/nodes/nodes.component';
import { SystemTrayComponent } from './pages/system-tray/system-tray.component';
export const routes: Routes = [
// System tray is standalone without layout
{ path: 'system-tray', component: SystemTrayComponent },
// All other routes use the main layout
{
path: '',
component: MainLayoutComponent,
children: [
{ path: '', redirectTo: 'workers', pathMatch: 'full' },
{ path: 'workers', component: WorkersComponent },
{ path: 'graphs', component: GraphsComponent },
{ path: 'console', component: ConsoleComponent },
{ path: 'pools', component: PoolsComponent },
{ path: 'profiles', component: ProfilesComponent },
{ path: 'miners', component: MinersComponent },
]
},
// Main app routes - MainLayoutComponent is rendered directly and contains router-outlet
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: GraphsComponent },
{ path: 'workers', component: WorkersComponent },
{ path: 'console', component: ConsoleComponent },
{ path: 'pools', component: PoolsComponent },
{ path: 'profiles', component: ProfilesComponent },
{ path: 'miners', component: MinersComponent },
{ path: 'nodes', component: NodesComponent },
];

View file

@ -10,7 +10,7 @@ import { MainLayoutComponent } from './layouts/main-layout.component';
imports: [
CommonModule,
SetupWizardComponent,
MainLayoutComponent
MainLayoutComponent,
],
templateUrl: './app.html',
styleUrls: ['./app.css'],

View file

@ -1,11 +1,55 @@
.chart-container {
width: 100%;
height: 300px;
min-height: 200px;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
gap: 1rem;
}
.chart-title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #e2e8f0;
}
.time-range-selector {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.time-range-btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: #94a3b8;
background: transparent;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.time-range-btn:hover {
color: #e2e8f0;
background: rgba(148, 163, 184, 0.1);
border-color: rgba(148, 163, 184, 0.3);
}
.time-range-btn.active {
color: var(--color-accent-400, #00d4ff);
background: rgba(0, 212, 255, 0.1);
border-color: var(--color-accent-500, #00d4ff);
}
.hashrate-chart {
width: 100%;
height: 300px;

View file

@ -1,4 +1,17 @@
<div class="chart-container">
<div class="chart-header">
<h3 class="chart-title">Hashrate History</h3>
<div class="time-range-selector">
@for (range of minerService.timeRanges; track range.minutes) {
<button
class="time-range-btn"
[class.active]="minerService.selectedTimeRange() === range.minutes"
(click)="minerService.setTimeRange(range.minutes)">
{{ range.label }}
</button>
}
</div>
</div>
<highcharts-chart
class="hashrate-chart"
[Highcharts]="Highcharts"

View file

@ -17,7 +17,7 @@ type SeriesWithData = Highcharts.SeriesAreaOptions | Highcharts.SeriesSplineOpti
encapsulation: ViewEncapsulation.None
})
export class ChartComponent {
private minerService = inject(MinerService);
minerService = inject(MinerService); // Public for template access
private destroyRef = inject(DestroyRef);
Highcharts: typeof Highcharts = Highcharts;
@ -60,7 +60,7 @@ export class ChartComponent {
...this.createBaseChartOptions().chart,
type: 'area'
},
title: { text: 'Total Hashrate' },
title: { text: '' },
plotOptions: {
area: {
stacking: 'normal',
@ -74,12 +74,8 @@ export class ChartComponent {
// Create effect with proper cleanup
const effectRef = effect(() => {
const historyMap = this.minerService.hashrateHistory();
// Skip if no data
if (historyMap.size === 0) {
return;
}
// Use 24-hour historical data from database
const historyMap = this.minerService.historicalHashrate();
// Clean up colors for miners no longer active
const activeNames = new Set(historyMap.keys());
@ -107,7 +103,7 @@ export class ChartComponent {
// Build new chart options
this.chartOptions = {
...this.createBaseChartOptions(),
title: { text: 'Total Hashrate' },
title: { text: '' },
chart: {
...this.createBaseChartOptions().chart,
type: 'area'

View file

@ -1,10 +1,11 @@
import { Component, signal, output, input } from '@angular/core';
import { Component, signal, output, input, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
interface NavItem {
id: string;
label: string;
icon: string;
icon: SafeHtml;
route: string;
}
@ -210,55 +211,61 @@ interface NavItem {
`]
})
export class SidebarComponent {
private sanitizer = inject(DomSanitizer);
collapsed = signal(false);
currentRoute = input<string>('workers');
currentRoute = input<string>('dashboard');
routeChange = output<string>();
navItems: NavItem[] = [
{
id: 'dashboard',
label: 'Dashboard',
route: 'dashboard',
icon: this.trustIcon('<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>')
},
{
id: 'workers',
label: 'Workers',
route: 'workers',
icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>'
},
{
id: 'graphs',
label: 'Graphs',
route: 'graphs',
icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>'
icon: this.trustIcon('<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>')
},
{
id: 'console',
label: 'Console',
route: 'console',
icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>'
icon: this.trustIcon('<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>')
},
{
id: 'pools',
label: 'Pools',
route: 'pools',
icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>'
icon: this.trustIcon('<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>')
},
{
id: 'profiles',
label: 'Profiles',
route: 'profiles',
icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>'
icon: this.trustIcon('<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>')
},
{
id: 'miners',
label: 'Miners',
route: 'miners',
icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'
icon: this.trustIcon('<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>')
},
{
id: 'nodes',
label: 'Nodes',
route: 'nodes',
icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01M9 12h.01M12 12h.01M15 12h.01"/></svg>'
icon: this.trustIcon('<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>')
}
];
private trustIcon(svg: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(svg);
}
toggleCollapse() {
this.collapsed.update(v => !v);
}

View file

@ -1,31 +1,21 @@
import { Component, signal } from '@angular/core';
import { Component, inject, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { filter, map } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop';
import { SidebarComponent } from '../components/sidebar/sidebar.component';
import { StatsPanelComponent } from '../components/stats-panel/stats-panel.component';
import { MinerSwitcherComponent } from '../components/miner-switcher/miner-switcher.component';
import { WorkersComponent } from '../pages/workers/workers.component';
import { GraphsComponent } from '../pages/graphs/graphs.component';
import { ConsoleComponent } from '../pages/console/console.component';
import { PoolsComponent } from '../pages/pools/pools.component';
import { ProfilesComponent } from '../pages/profiles/profiles.component';
import { MinersComponent } from '../pages/miners/miners.component';
import { NodesComponent } from '../pages/nodes/nodes.component';
@Component({
selector: 'app-main-layout',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
SidebarComponent,
StatsPanelComponent,
MinerSwitcherComponent,
WorkersComponent,
GraphsComponent,
ConsoleComponent,
PoolsComponent,
ProfilesComponent,
MinersComponent,
NodesComponent
],
template: `
<div class="main-layout">
@ -38,32 +28,7 @@ import { NodesComponent } from '../pages/nodes/nodes.component';
</div>
<div class="page-content">
@switch (currentRoute()) {
@case ('workers') {
<app-workers></app-workers>
}
@case ('graphs') {
<app-graphs></app-graphs>
}
@case ('console') {
<app-console></app-console>
}
@case ('pools') {
<app-pools></app-pools>
}
@case ('profiles') {
<app-profiles></app-profiles>
}
@case ('miners') {
<app-miners></app-miners>
}
@case ('nodes') {
<app-nodes></app-nodes>
}
@default {
<app-workers></app-workers>
}
}
<router-outlet></router-outlet>
</div>
</div>
</div>
@ -102,17 +67,42 @@ import { NodesComponent } from '../pages/nodes/nodes.component';
}
`]
})
export class MainLayoutComponent {
currentRoute = signal('workers');
private editingProfileId: string | null = null;
export class MainLayoutComponent implements AfterViewInit {
private router = inject(Router);
// Track current route from router events
currentRoute = toSignal(
this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map(event => {
// Extract route from URL like "/#/workers" or "/workers"
const url = event.urlAfterRedirects;
const segments = url.split('/').filter(s => s && s !== '#');
return segments[0] || 'dashboard';
})
),
{ initialValue: this.getInitialRoute() }
);
private getInitialRoute(): string {
const url = this.router.url;
const segments = url.split('/').filter(s => s && s !== '#');
return segments[0] || 'dashboard';
}
ngAfterViewInit() {
// Re-trigger navigation after router-outlet is available
// This handles the case where router tried to navigate before outlet existed
const route = this.getInitialRoute();
setTimeout(() => this.router.navigate(['/', route]), 0);
}
onRouteChange(route: string) {
this.currentRoute.set(route);
this.router.navigate(['/', route]);
}
navigateToProfiles(profileId: string) {
this.editingProfileId = profileId;
this.currentRoute.set('profiles');
// TODO: Could emit event to profiles page to open edit modal for this profile
// TODO: Could pass profileId via query params or state
this.router.navigate(['/', 'profiles']);
}
}

View file

@ -63,6 +63,24 @@ export class MinerService implements OnDestroy {
// Separate signal for hashrate history as it updates frequently
public hashrateHistory = signal<Map<string, HashratePoint[]>>(new Map());
// Historical hashrate data from database with configurable time range
public historicalHashrate = signal<Map<string, HashratePoint[]>>(new Map());
public selectedTimeRange = signal<number>(60); // Default 60 minutes
private historyPollingSubscription?: Subscription;
// Available time ranges in minutes
public readonly timeRanges = [
{ label: '5m', minutes: 5 },
{ label: '15m', minutes: 15 },
{ label: '30m', minutes: 30 },
{ label: '45m', minutes: 45 },
{ label: '1h', minutes: 60 },
{ label: '3h', minutes: 180 },
{ label: '6h', minutes: 360 },
{ label: '12h', minutes: 720 },
{ label: '24h', minutes: 1440 },
];
// --- View Mode Signals (single/multi miner view) ---
public viewMode = signal<'all' | 'single'>('all');
public selectedMinerName = signal<string | null>(null);
@ -92,10 +110,12 @@ export class MinerService implements OnDestroy {
constructor(private http: HttpClient) {
this.forceRefreshState();
this.startPollingLive_Data();
this.startPollingHistoricalData();
}
ngOnDestroy(): void {
this.stopPolling();
this.historyPollingSubscription?.unsubscribe();
}
// --- Data Loading and Polling Logic ---
@ -116,6 +136,8 @@ export class MinerService implements OnDestroy {
if (initialState) {
this.state.set(initialState);
this.updateHashrateHistory(initialState.runningMiners);
// Fetch historical data now that we know which miners are running
this.fetchHistoricalHashrate();
}
});
}
@ -132,6 +154,66 @@ export class MinerService implements OnDestroy {
});
}
/**
* Starts a polling interval to fetch historical data from database.
* Polls every 30 seconds. Initial fetch happens in forceRefreshState after miners are loaded.
*/
private startPollingHistoricalData() {
// Poll every 30 seconds (initial fetch happens in forceRefreshState)
this.historyPollingSubscription = interval(30000).subscribe(() => {
this.fetchHistoricalHashrate();
});
}
/**
* Fetches 24-hour historical hashrate data for all running miners from the database.
*/
private fetchHistoricalHashrate() {
const runningMiners = this.state().runningMiners;
if (runningMiners.length === 0) {
this.historicalHashrate.set(new Map());
return;
}
// Fetch historical data for each running miner
const requests = runningMiners.map(miner =>
this.getHistoricalHashrateForMiner(miner.name).pipe(
map(data => ({ name: miner.name, data })),
catchError(() => of({ name: miner.name, data: [] as HashratePoint[] }))
)
);
forkJoin(requests).subscribe(results => {
const newHistory = new Map<string, HashratePoint[]>();
results.forEach(result => {
if (result.data && result.data.length > 0) {
newHistory.set(result.name, result.data);
}
});
this.historicalHashrate.set(newHistory);
});
}
/**
* Fetches historical hashrate for a specific miner based on selected time range.
*/
private getHistoricalHashrateForMiner(minerName: string) {
const minutes = this.selectedTimeRange();
const since = new Date(Date.now() - minutes * 60 * 1000).toISOString();
const until = new Date().toISOString();
return this.http.get<HashratePoint[]>(
`${this.apiBaseUrl}/history/miners/${minerName}/hashrate?since=${since}&until=${until}`
);
}
/**
* Sets the time range for historical data and refreshes immediately.
*/
public setTimeRange(minutes: number) {
this.selectedTimeRange.set(minutes);
this.fetchHistoricalHashrate();
}
private stopPolling() {
this.pollingSubscription?.unsubscribe();
}
@ -185,7 +267,24 @@ export class MinerService implements OnDestroy {
}
getMinerLogs(minerName: string) {
return this.http.get<string[]>(`${this.apiBaseUrl}/miners/${minerName}/logs`);
return this.http.get<string[]>(`${this.apiBaseUrl}/miners/${minerName}/logs`).pipe(
map(logs => logs.map(line => {
try {
// Decode base64 encoded log lines
return atob(line);
} catch {
// If decoding fails, return the original line
return line;
}
}))
);
}
sendStdin(minerName: string, input: string) {
return this.http.post<{status: string, input: string}>(
`${this.apiBaseUrl}/miners/${minerName}/stdin`,
{ input }
);
}
createProfile(profile: MiningProfile) {

View file

@ -1,7 +1,7 @@
import { Component, inject, computed, signal, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewChecked } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { MinerService } from '../../miner.service';
import { HttpClient } from '@angular/common/http';
import { interval, Subscription, switchMap } from 'rxjs';
@Component({
@ -58,7 +58,7 @@ import { interval, Subscription, switchMap } from 'rxjs';
@if (logs().length > 0) {
@for (line of logs(); track $index) {
<div class="log-line" [class.error]="isErrorLine(line)" [class.warning]="isWarningLine(line)">
<span class="log-text">{{ line }}</span>
<span class="log-text" [innerHTML]="ansiToHtml(line)"></span>
</div>
}
} @else if (activeMiner()) {
@ -75,6 +75,19 @@ import { interval, Subscription, switchMap } from 'rxjs';
}
</div>
<!-- Console Input -->
<div class="console-input-wrapper">
<span class="input-prompt">></span>
<input
type="text"
class="console-input"
placeholder="Type command (h=hashrate, p=pause, r=resume, s=results, c=connection)"
[value]="stdinInput()"
(input)="onStdinInput($event)"
(keydown.enter)="sendStdinCommand()"
[disabled]="!activeMiner()">
</div>
<!-- Console Controls -->
<div class="console-controls">
<label class="control-checkbox">
@ -259,6 +272,49 @@ import { interval, Subscription, switchMap } from 'rxjs';
font-size: 0.875rem;
}
.console-input-wrapper {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background: rgba(10, 10, 18, 0.6);
backdrop-filter: blur(4px);
border-left: 1px solid rgb(37 37 66 / 0.2);
border-right: 1px solid rgb(37 37 66 / 0.2);
}
.input-prompt {
color: var(--color-accent-500);
font-family: var(--font-family-mono);
font-size: 0.875rem;
margin-right: 0.5rem;
opacity: 0.7;
}
.console-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: rgba(163, 230, 53, 0.8);
font-family: var(--font-family-mono);
font-size: 0.8125rem;
caret-color: var(--color-accent-500);
}
.console-input::placeholder {
color: rgba(100, 116, 139, 0.4);
font-style: italic;
}
.console-input:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.console-input:focus {
color: #a3e635;
}
.console-controls {
display: flex;
align-items: center;
@ -314,7 +370,7 @@ export class ConsoleComponent implements OnInit, OnDestroy, AfterViewChecked {
@ViewChild('consoleOutput') consoleOutput!: ElementRef;
private minerService = inject(MinerService);
private http = inject(HttpClient);
private sanitizer = inject(DomSanitizer);
private state = this.minerService.state;
private pollSub?: Subscription;
@ -337,13 +393,16 @@ export class ConsoleComponent implements OnInit, OnDestroy, AfterViewChecked {
logs = signal<string[]>([]);
autoScroll = signal(true);
stdinInput = signal('');
private shouldScroll = false;
ngOnInit() {
// Auto-select first miner for console view
// Auto-select first miner for console view and fetch logs immediately
const miners = this.runningMiners();
if (miners.length > 0) {
this.consoleSelectedMiner.set(miners[0].name);
// Fetch logs immediately - don't wait for interval
this.fetchLogs(miners[0].name);
}
// Poll for logs every 2 seconds
@ -351,12 +410,12 @@ export class ConsoleComponent implements OnInit, OnDestroy, AfterViewChecked {
switchMap(() => {
const miner = this.activeMiner();
if (!miner) return [];
return this.http.get<{logs: string[]}>(`/api/v1/mining/miners/${miner}/logs`);
return this.minerService.getMinerLogs(miner);
})
).subscribe({
next: (response: any) => {
if (response?.logs) {
this.logs.set(response.logs);
next: (logs: string[]) => {
if (logs && Array.isArray(logs)) {
this.logs.set(logs);
if (this.autoScroll()) {
this.shouldScroll = true;
}
@ -385,10 +444,10 @@ export class ConsoleComponent implements OnInit, OnDestroy, AfterViewChecked {
}
private fetchLogs(minerName: string) {
this.http.get<{logs: string[]}>(`/api/v1/mining/miners/${minerName}/logs`).subscribe({
next: (response) => {
if (response?.logs) {
this.logs.set(response.logs);
this.minerService.getMinerLogs(minerName).subscribe({
next: (logs) => {
if (logs && Array.isArray(logs)) {
this.logs.set(logs);
this.shouldScroll = true;
}
}
@ -408,6 +467,26 @@ export class ConsoleComponent implements OnInit, OnDestroy, AfterViewChecked {
this.selectConsoleMiner(select.value);
}
onStdinInput(event: Event) {
const input = event.target as HTMLInputElement;
this.stdinInput.set(input.value);
}
sendStdinCommand() {
const miner = this.activeMiner();
const input = this.stdinInput();
if (!miner || !input.trim()) return;
this.minerService.sendStdin(miner, input).subscribe({
next: () => {
this.stdinInput.set('');
},
error: (err) => {
console.error('Failed to send stdin:', err);
}
});
}
isErrorLine(line: string): boolean {
const lower = line.toLowerCase();
return lower.includes('error') || lower.includes('failed') || lower.includes('fatal');
@ -417,4 +496,65 @@ export class ConsoleComponent implements OnInit, OnDestroy, AfterViewChecked {
const lower = line.toLowerCase();
return lower.includes('warn') || lower.includes('timeout') || lower.includes('retry');
}
// Convert ANSI escape codes to HTML with CSS styling
ansiToHtml(text: string): SafeHtml {
// ANSI color codes mapping
const colors: { [key: string]: string } = {
'30': '#1e1e1e', '31': '#ef4444', '32': '#22c55e', '33': '#eab308',
'34': '#3b82f6', '35': '#a855f7', '36': '#06b6d4', '37': '#e5e5e5',
'90': '#737373', '91': '#fca5a5', '92': '#86efac', '93': '#fde047',
'94': '#93c5fd', '95': '#d8b4fe', '96': '#67e8f9', '97': '#ffffff',
};
const bgColors: { [key: string]: string } = {
'40': '#1e1e1e', '41': '#dc2626', '42': '#16a34a', '43': '#ca8a04',
'44': '#2563eb', '45': '#9333ea', '46': '#0891b2', '47': '#d4d4d4',
};
let html = this.escapeHtml(text);
let currentStyles: string[] = [];
// Process ANSI escape sequences
html = html.replace(/\x1b\[([0-9;]*)m/g, (_, codes) => {
if (!codes || codes === '0') {
currentStyles = [];
return '</span>';
}
const codeList = codes.split(';');
const styles: string[] = [];
for (const code of codeList) {
if (code === '1') styles.push('font-weight:bold');
else if (code === '3') styles.push('font-style:italic');
else if (code === '4') styles.push('text-decoration:underline');
else if (colors[code]) styles.push(`color:${colors[code]}`);
else if (bgColors[code]) styles.push(`background:${bgColors[code]};padding:0 2px`);
}
if (styles.length > 0) {
currentStyles = styles;
return `<span style="${styles.join(';')}">`;
}
return '';
});
// Clean up any unclosed spans
const openSpans = (html.match(/<span/g) || []).length;
const closeSpans = (html.match(/<\/span>/g) || []).length;
for (let i = 0; i < openSpans - closeSpans; i++) {
html += '</span>';
}
return this.sanitizer.bypassSecurityTrustHtml(html);
}
private escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
}