diff --git a/docs/tasks/backlog/P10-T100.md b/docs/tasks/done/P10-T100.md similarity index 69% rename from docs/tasks/backlog/P10-T100.md rename to docs/tasks/done/P10-T100.md index 19b58b0..df44bc6 100644 --- a/docs/tasks/backlog/P10-T100.md +++ b/docs/tasks/done/P10-T100.md @@ -1,7 +1,7 @@ # P10-T100: Implement "Worker Detail" page (SSR fragment) **Phase:** P10 - Dashboard & Monitoring UI -**Status:** Not Started +**Status:** Completed **Priority:** Medium **Dependencies:** [P10-T070] **Estimated Effort:** Medium @@ -14,12 +14,12 @@ Create a detailed profile page for each worker. This page aggregates real-time s ## Acceptance Criteria -- [ ] Create route `/dashboard/workers/{worker_id}`. -- [ ] Render worker meta-data (ID, Version, OS/HW Type). -- [ ] Display a worker-specific throughput real-time chart. -- [ ] Display historical volume bar chart (7 days). -- [ ] Add a "Recent Log" or "Latest Checkpoints" list for that specific worker. -- [ ] Allow clicking a worker in the table (P10-T050) to open this view. +- [x] Create route `/dashboard/workers/{worker_id}`. +- [x] Render worker meta-data (ID, Version, OS/HW Type). +- [x] Display a worker-specific throughput real-time chart. +- [x] Display historical volume bar chart (7 days). +- [x] Add a "Recent Log" or "Latest Checkpoints" list for that specific worker. +- [x] Allow clicking a worker in the table (P10-T050) to open this view. ## Implementation Notes diff --git a/go/internal/database/acceptance_optimization_test.go b/go/internal/database/acceptance_optimization_test.go index 3a3b06b..cee9861 100644 --- a/go/internal/database/acceptance_optimization_test.go +++ b/go/internal/database/acceptance_optimization_test.go @@ -139,8 +139,8 @@ func TestAutomaticAggregationFromHistory(t *testing.T) { if err != nil { t.Fatalf("GetWorkerLifetimeStats error: %v", err) } - if !lifetime.TotalBatches.Valid || lifetime.TotalBatches.Int64 < 100 { - t.Fatalf("expected lifetime.total_batches >= 100, got %+v", lifetime.TotalBatches) + if lifetime.TotalBatches < 100 { + t.Fatalf("expected lifetime.total_batches >= 100, got %d", lifetime.TotalBatches) } } diff --git a/go/internal/database/queries.sql.go b/go/internal/database/queries.sql.go index a5521a9..2fe7011 100644 --- a/go/internal/database/queries.sql.go +++ b/go/internal/database/queries.sql.go @@ -246,10 +246,14 @@ SELECT j.current_nonce, j.nonce_start, j.nonce_end, - (SELECT h.keys_per_second - FROM worker_history h - WHERE h.worker_id = w.id - ORDER BY h.finished_at DESC LIMIT 1) as last_kps + COALESCE( + (SELECT CAST(j2.keys_scanned AS REAL) / (CAST(j2.duration_ms AS REAL) / 1000.0) + FROM jobs j2 WHERE j2.id = j.id AND j2.duration_ms > 0), + (SELECT h.keys_per_second + FROM worker_history h + WHERE h.worker_id = w.id + ORDER BY h.finished_at DESC LIMIT 1) + ) as last_kps FROM workers w LEFT JOIN jobs j ON j.worker_id = w.id AND j.status = 'processing' WHERE w.last_seen > datetime('now', '-5 minutes') @@ -257,15 +261,15 @@ ORDER BY w.last_seen DESC ` type GetActiveWorkerDetailsRow struct { - ID string `json:"id"` - WorkerType string `json:"worker_type"` - LastSeen time.Time `json:"last_seen"` - TotalKeysScanned sql.NullInt64 `json:"total_keys_scanned"` - ActivePrefix []byte `json:"active_prefix"` - CurrentNonce sql.NullInt64 `json:"current_nonce"` - NonceStart sql.NullInt64 `json:"nonce_start"` - NonceEnd sql.NullInt64 `json:"nonce_end"` - LastKps sql.NullFloat64 `json:"last_kps"` + ID string `json:"id"` + WorkerType string `json:"worker_type"` + LastSeen time.Time `json:"last_seen"` + TotalKeysScanned sql.NullInt64 `json:"total_keys_scanned"` + ActivePrefix []byte `json:"active_prefix"` + CurrentNonce sql.NullInt64 `json:"current_nonce"` + NonceStart sql.NullInt64 `json:"nonce_start"` + NonceEnd sql.NullInt64 `json:"nonce_end"` + LastKps interface{} `json:"last_kps"` } // Get detailed info about currently active workers for dashboard @@ -697,7 +701,8 @@ SELECT keys_scanned, expires_at, created_at, last_checkpoint_at FROM jobs WHERE prefix_28 = ? -ORDER BY nonce_start ASC +ORDER BY created_at DESC +LIMIT 20 ` type GetJobsByPrefixRow struct { @@ -1159,6 +1164,44 @@ func (q *Queries) GetResultsByAddress(ctx context.Context, address string) ([]Re return items, nil } +const getResultsByWorker = `-- name: GetResultsByWorker :many +SELECT id, private_key, address, worker_id, job_id, nonce_found, found_at FROM results +WHERE worker_id = ? +ORDER BY found_at DESC +` + +// Find results by worker ID +func (q *Queries) GetResultsByWorker(ctx context.Context, workerID string) ([]Result, error) { + rows, err := q.db.QueryContext(ctx, getResultsByWorker, workerID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Result{} + for rows.Next() { + var i Result + if err := rows.Scan( + &i.ID, + &i.PrivateKey, + &i.Address, + &i.WorkerID, + &i.JobID, + &i.NonceFound, + &i.FoundAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getStats = `-- name: GetStats :one SELECT pending_batches, processing_batches, completed_batches, total_batches, total_keys_scanned, avg_pc_batch_size, avg_esp32_batch_size, results_found, total_workers, active_workers, pc_workers, esp32_workers, global_keys_per_second, active_prefixes FROM stats_summary ` @@ -1290,6 +1333,56 @@ func (q *Queries) GetWorkerDailyStats(ctx context.Context, arg GetWorkerDailySta return items, nil } +const getWorkerHistoryLogs = `-- name: GetWorkerHistoryLogs :many +SELECT id, worker_id, worker_type, job_id, batch_size, keys_scanned, duration_ms, keys_per_second, prefix_28, nonce_start, nonce_end, finished_at, error_message FROM worker_history +WHERE worker_id = ? +ORDER BY finished_at DESC +LIMIT ? +` + +type GetWorkerHistoryLogsParams struct { + WorkerID string `json:"worker_id"` + Limit int64 `json:"limit"` +} + +// Get latest history logs for a specific worker +func (q *Queries) GetWorkerHistoryLogs(ctx context.Context, arg GetWorkerHistoryLogsParams) ([]WorkerHistory, error) { + rows, err := q.db.QueryContext(ctx, getWorkerHistoryLogs, arg.WorkerID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []WorkerHistory{} + for rows.Next() { + var i WorkerHistory + if err := rows.Scan( + &i.ID, + &i.WorkerID, + &i.WorkerType, + &i.JobID, + &i.BatchSize, + &i.KeysScanned, + &i.DurationMs, + &i.KeysPerSecond, + &i.Prefix28, + &i.NonceStart, + &i.NonceEnd, + &i.FinishedAt, + &i.ErrorMessage, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkerLastPrefix = `-- name: GetWorkerLastPrefix :one SELECT prefix_28, MAX(nonce_end) as highest_nonce FROM jobs @@ -1313,25 +1406,55 @@ func (q *Queries) GetWorkerLastPrefix(ctx context.Context, workerID sql.NullStri } const getWorkerLifetimeStats = `-- name: GetWorkerLifetimeStats :one -SELECT worker_id, worker_type, total_batches, total_keys_scanned, total_duration_ms, keys_per_second_avg, keys_per_second_best, keys_per_second_worst, first_seen_at, last_seen_at FROM worker_stats_lifetime -WHERE worker_id = ? LIMIT 1 +SELECT + CAST(SUM(total_batches) AS INTEGER) as total_batches, + CAST(SUM(total_keys_scanned) AS INTEGER) as total_keys_scanned, + CAST(SUM(total_duration_ms) AS INTEGER) as total_duration_ms, + COALESCE(AVG(keys_per_second_avg), 0.0) as keys_per_second_avg, + COALESCE(MAX(keys_per_second_best), 0.0) as keys_per_second_best +FROM ( + -- Archived data (Tier 4) + SELECT + total_batches, + total_keys_scanned, + total_duration_ms, + keys_per_second_avg, + keys_per_second_best + FROM worker_stats_lifetime + WHERE worker_stats_lifetime.worker_id = ?1 + + UNION ALL + + -- Recent data (Tier 1) + SELECT + 1 as total_batches, + keys_scanned as total_keys_scanned, + duration_ms as total_duration_ms, + keys_per_second as keys_per_second_avg, + keys_per_second as keys_per_second_best + FROM worker_history + WHERE worker_history.worker_id = ?1 +) AS unified ` -// Get lifetime stats for a worker -func (q *Queries) GetWorkerLifetimeStats(ctx context.Context, workerID string) (WorkerStatsLifetime, error) { +type GetWorkerLifetimeStatsRow struct { + TotalBatches int64 `json:"total_batches"` + TotalKeysScanned int64 `json:"total_keys_scanned"` + TotalDurationMs int64 `json:"total_duration_ms"` + KeysPerSecondAvg interface{} `json:"keys_per_second_avg"` + KeysPerSecondBest interface{} `json:"keys_per_second_best"` +} + +// Get unified lifetime stats for a single worker +func (q *Queries) GetWorkerLifetimeStats(ctx context.Context, workerID string) (GetWorkerLifetimeStatsRow, error) { row := q.db.QueryRowContext(ctx, getWorkerLifetimeStats, workerID) - var i WorkerStatsLifetime + var i GetWorkerLifetimeStatsRow err := row.Scan( - &i.WorkerID, - &i.WorkerType, &i.TotalBatches, &i.TotalKeysScanned, &i.TotalDurationMs, &i.KeysPerSecondAvg, &i.KeysPerSecondBest, - &i.KeysPerSecondWorst, - &i.FirstSeenAt, - &i.LastSeenAt, ) return i, err } diff --git a/go/internal/database/sql/queries.sql b/go/internal/database/sql/queries.sql index f57e5de..6ff43bd 100644 --- a/go/internal/database/sql/queries.sql +++ b/go/internal/database/sql/queries.sql @@ -131,6 +131,12 @@ SELECT * FROM results WHERE address = ? ORDER BY found_at DESC; +-- name: GetResultsByWorker :many +-- Find results by worker ID +SELECT * FROM results +WHERE worker_id = ? +ORDER BY found_at DESC; + -- name: GetAllResults :many -- Get all results (limited) SELECT * FROM results @@ -183,10 +189,14 @@ SELECT j.current_nonce, j.nonce_start, j.nonce_end, - (SELECT h.keys_per_second - FROM worker_history h - WHERE h.worker_id = w.id - ORDER BY h.finished_at DESC LIMIT 1) as last_kps + COALESCE( + (SELECT CAST(j2.keys_scanned AS REAL) / (CAST(j2.duration_ms AS REAL) / 1000.0) + FROM jobs j2 WHERE j2.id = j.id AND j2.duration_ms > 0), + (SELECT h.keys_per_second + FROM worker_history h + WHERE h.worker_id = w.id + ORDER BY h.finished_at DESC LIMIT 1) + ) as last_kps FROM workers w LEFT JOIN jobs j ON j.worker_id = w.id AND j.status = 'processing' WHERE w.last_seen > datetime('now', '-5 minutes') @@ -198,6 +208,13 @@ SELECT * FROM workers WHERE worker_type = ? ORDER BY last_seen DESC; +-- name: GetWorkerHistoryLogs :many +-- Get latest history logs for a specific worker +SELECT * FROM worker_history +WHERE worker_id = ? +ORDER BY finished_at DESC +LIMIT ?; + -- name: GetStats :one -- Get aggregated statistics SELECT * FROM stats_summary; @@ -236,7 +253,8 @@ SELECT keys_scanned, expires_at, created_at, last_checkpoint_at FROM jobs WHERE prefix_28 = ? -ORDER BY nonce_start ASC; +ORDER BY created_at DESC +LIMIT 20; -- name: RecordWorkerStats :exec -- Insert a raw worker history record (tier 1) @@ -426,9 +444,36 @@ ORDER BY total_keys DESC LIMIT 1; -- name: GetWorkerLifetimeStats :one --- Get lifetime stats for a worker -SELECT * FROM worker_stats_lifetime -WHERE worker_id = ? LIMIT 1; +-- Get unified lifetime stats for a single worker +SELECT + CAST(SUM(total_batches) AS INTEGER) as total_batches, + CAST(SUM(total_keys_scanned) AS INTEGER) as total_keys_scanned, + CAST(SUM(total_duration_ms) AS INTEGER) as total_duration_ms, + COALESCE(AVG(keys_per_second_avg), 0.0) as keys_per_second_avg, + COALESCE(MAX(keys_per_second_best), 0.0) as keys_per_second_best +FROM ( + -- Archived data (Tier 4) + SELECT + total_batches, + total_keys_scanned, + total_duration_ms, + keys_per_second_avg, + keys_per_second_best + FROM worker_stats_lifetime + WHERE worker_stats_lifetime.worker_id = :worker_id + + UNION ALL + + -- Recent data (Tier 1) + SELECT + 1 as total_batches, + keys_scanned as total_keys_scanned, + duration_ms as total_duration_ms, + keys_per_second as keys_per_second_avg, + keys_per_second as keys_per_second_best + FROM worker_history + WHERE worker_history.worker_id = :worker_id +) AS unified; -- name: GetAllWorkerLifetimeStats :many -- Get unified lifetime stats for all workers, combining archived tier 4 and recent tier 1 diff --git a/go/internal/database/stats_test.go b/go/internal/database/stats_test.go index 8c6035d..5e9fb59 100644 --- a/go/internal/database/stats_test.go +++ b/go/internal/database/stats_test.go @@ -101,8 +101,8 @@ func TestRecordWorkerHistoryAndAggregation(t *testing.T) { if err != nil { t.Fatalf("GetWorkerLifetimeStats error: %v", err) } - if !lifetime.TotalBatches.Valid || lifetime.TotalBatches.Int64 < 1 { - t.Fatalf("expected lifetime.total_batches >= 1, got %+v", lifetime.TotalBatches) + if lifetime.TotalBatches < 1 { + t.Fatalf("expected lifetime.total_batches >= 1, got %d", lifetime.TotalBatches) } _ = db // silence unused in some contexts diff --git a/go/internal/server/checkpoint.go b/go/internal/server/checkpoint.go index 01de7a4..d3fa3b6 100644 --- a/go/internal/server/checkpoint.go +++ b/go/internal/server/checkpoint.go @@ -104,13 +104,22 @@ func (s *Server) handleJobCheckpoint(w http.ResponseWriter, r *http.Request) { return } - // Calculate deltas for worker_history before updating job state + // Calculate deltas and range for worker_history before updating job state deltaKeys := req.KeysScanned - job.KeysScanned.Int64 deltaDuration := req.DurationMs - job.DurationMs.Int64 + // Nonce range for THIS delta period (since last checkpoint) + // Start is either original nonce_start (if first checkpoint) or last checkpoint's current_nonce + 1 + rangeStart := job.NonceStart + if job.CurrentNonce.Valid && job.KeysScanned.Int64 > 0 { + rangeStart = job.CurrentNonce.Int64 + 1 + } + rangeEnd := req.CurrentNonce + // Sanity check: if deltas are negative, fallback to full reported values if deltaKeys < 0 { deltaKeys = req.KeysScanned + rangeStart = job.NonceStart // reset range to full if we lost delta context } if deltaDuration < 0 { deltaDuration = req.DurationMs @@ -188,8 +197,8 @@ func (s *Server) handleJobCheckpoint(w http.ResponseWriter, r *http.Request) { dd, // delta duration kps, updated.Prefix28, - updated.NonceStart, - req.CurrentNonce, + rangeStart, + rangeEnd, ) if err != nil { log.Printf("WARNING: failed to record worker stats on checkpoint: %v", err) diff --git a/go/internal/server/complete.go b/go/internal/server/complete.go index ff5a199..e7397ae 100644 --- a/go/internal/server/complete.go +++ b/go/internal/server/complete.go @@ -96,13 +96,21 @@ func (s *Server) handleJobComplete(w http.ResponseWriter, r *http.Request) { return } - // Calculate deltas for worker_history before updating job state + // Calculate deltas and range for worker_history before final update deltaKeys := req.KeysScanned - job.KeysScanned.Int64 deltaDuration := req.DurationMs - job.DurationMs.Int64 + // Nonce range for THIS final period (since last checkpoint) + rangeStart := job.NonceStart + if job.CurrentNonce.Valid && job.KeysScanned.Int64 > 0 { + rangeStart = job.CurrentNonce.Int64 + 1 + } + rangeEnd := req.FinalNonce + // Sanity check: if deltas are negative, fallback to full reported values if deltaKeys < 0 { deltaKeys = req.KeysScanned + rangeStart = job.NonceStart } if deltaDuration < 0 { deltaDuration = req.DurationMs @@ -183,8 +191,8 @@ func (s *Server) handleJobComplete(w http.ResponseWriter, r *http.Request) { dd, // delta duration kps, updated.Prefix28, - updated.NonceStart, - updated.NonceEnd, + rangeStart, + rangeEnd, ) if err != nil { log.Printf("WARNING: failed to record worker stats on complete: %v", err) diff --git a/go/internal/server/hub.go b/go/internal/server/hub.go index 0421b2c..515524d 100644 --- a/go/internal/server/hub.go +++ b/go/internal/server/hub.go @@ -223,25 +223,25 @@ func (s *Server) broadcastStats(ctx context.Context) { } data := struct { - ActiveWorkers int64 + ActiveWorkerCount int64 TotalKeysScanned int64 CompletedJobCount int64 ProcessingJobCount int64 PendingJobCount int64 TotalWorkers int64 GlobalKeysPerSecond float64 - ActiveWorkersList []database.GetActiveWorkerDetailsRow + ActiveWorkers []database.GetActiveWorkerDetailsRow PrefixProgress []database.GetPrefixProgressRow NowTimestamp int64 }{ - ActiveWorkers: stats.ActiveWorkers, + ActiveWorkerCount: stats.ActiveWorkers, TotalKeysScanned: totalKeys, CompletedJobCount: stats.CompletedBatches, ProcessingJobCount: stats.ProcessingBatches, PendingJobCount: stats.PendingBatches, TotalWorkers: stats.TotalWorkers, GlobalKeysPerSecond: globalThroughput, - ActiveWorkersList: activeWorkers, + ActiveWorkers: activeWorkers, PrefixProgress: prefixProgress, NowTimestamp: time.Now().Unix(), } diff --git a/go/internal/server/ui/renderer.go b/go/internal/server/ui/renderer.go index 4e72ead..509a49b 100644 --- a/go/internal/server/ui/renderer.go +++ b/go/internal/server/ui/renderer.go @@ -163,6 +163,20 @@ func (r *TemplateRenderer) loadTemplates() error { // #nosec G203 -- calculated percentage is safe return template.HTMLAttr(fmt.Sprintf("style=\"width: %.2f%%\"", p)) }, + "chartHeightStyle": func(current int64, maxi int64) template.HTMLAttr { + if maxi <= 0 { + return template.HTMLAttr("style=\"height: 4px; min-height: 4px;\"") + } + p := (float64(current) / float64(maxi)) * 100 + if p < 1 && current > 0 { + p = 1 + } + if p > 100 { + p = 100 + } + // #nosec G203 -- calculated height percentage is safe + return template.HTMLAttr(fmt.Sprintf("style=\"height: %.1f%%; min-height: 4px;\"", p)) + }, "workerIconClass": func(workerType any) string { wt := "" switch v := workerType.(type) { @@ -185,6 +199,20 @@ func (r *TemplateRenderer) loadTemplates() error { "subtract": func(a, b int64) int64 { return a - b }, + "formatCount": func(n int64) string { + if n < 0 { + return fmt.Sprintf("%d", n) + } + s := fmt.Sprintf("%d", n) + var res []byte + for i, j := len(s)-1, 0; i >= 0; i, j = i-1, j+1 { + if j > 0 && j%3 == 0 { + res = append([]byte{','}, res...) + } + res = append([]byte{s[i]}, res...) + } + return string(res) + }, "int": func(v any) int64 { switch val := v.(type) { case int64: @@ -193,6 +221,10 @@ func (r *TemplateRenderer) loadTemplates() error { return int64(val) case uint32: return int64(val) + case sql.NullInt64: + if val.Valid { + return val.Int64 + } } return 0 }, @@ -206,6 +238,14 @@ func (r *TemplateRenderer) loadTemplates() error { return float64(val) case uint32: return float64(val) + case sql.NullFloat64: + if val.Valid { + return val.Float64 + } + case sql.NullInt64: + if val.Valid { + return float64(val.Int64) + } } return 0 }, @@ -303,7 +343,15 @@ func (r *TemplateRenderer) loadTemplates() error { // #nosec G203 -- hardcoded link path with hex value is safe return template.HTMLAttr(fmt.Sprintf(`href="/dashboard/prefixes/0x%s"`, s)) }, - "errorTextClass": func(errVal any) string { + "workerLinkAttr": func(id any) template.HTMLAttr { + // #nosec G203 -- hardcoded link path with id + return template.HTMLAttr(fmt.Sprintf(`href="/dashboard/workers/%v"`, id)) + }, + "workerStatsLinkAttr": func(id any) template.HTMLAttr { + // #nosec G203 -- hardcoded link path with id + return template.HTMLAttr(fmt.Sprintf(`href="/dashboard/daily?worker_id=%v"`, id)) + }, + "errorStatusAttr": func(errVal any) template.HTMLAttr { var count float64 switch v := errVal.(type) { case int: @@ -322,10 +370,68 @@ func (r *TemplateRenderer) loadTemplates() error { } } + classes := "text-gray-400 font-bold" if count > 0 { - return "text-red-500 font-black" + classes = "text-red-500 font-black" + } + // #nosec G203 -- classes are safe + return template.HTMLAttr(fmt.Sprintf(`class="%s"`, classes)) + }, + "dataAttr": func(name string, val any) template.HTMLAttr { + // #nosec G203 -- name is internal, val is escaped by %v + return template.HTMLAttr(fmt.Sprintf(`data-%s="%v"`, name, val)) + }, + "dataFloatAttr": func(name string, v any, precision int) template.HTMLAttr { + val := float64(0) + if v != nil { + switch vt := v.(type) { + case float64: + val = vt + case float32: + val = float64(vt) + case int64: + val = float64(vt) + case int: + val = float64(vt) + case uint32: + val = float64(vt) + case sql.NullFloat64: + if vt.Valid { + val = vt.Float64 + } + case sql.NullInt64: + if vt.Valid { + val = float64(vt.Int64) + } + } + } + format := fmt.Sprintf(`data-%%s="%%.%df"`, precision) + // #nosec G203 -- name is internal, value is formatted float + return template.HTMLAttr(fmt.Sprintf(format, name, val)) + }, + "titleAttr": func(v any) template.HTMLAttr { + var s string + switch val := v.(type) { + case []byte: + s = fmt.Sprintf("0x%x", val) + case [28]byte: + s = fmt.Sprintf("0x%x", val[:]) + case string: + s = val + default: + s = fmt.Sprintf("%v", val) + } + // #nosec G203 -- value is escaped by %s + return template.HTMLAttr(fmt.Sprintf(`title="%s"`, s)) + }, + "historyStatusAttr": func(msg sql.NullString) template.HTMLAttr { + base := "px-6 py-3 whitespace-nowrap uppercase text-[10px] font-black" + if msg.Valid && msg.String != "" { + // #nosec G203 -- hardcoded classes are safe + return template.HTMLAttr(fmt.Sprintf(`class="%s text-red-500"`, base)) } - return "text-gray-400 font-bold" + // #nosec G203 -- hardcoded classes are safe + return template.HTMLAttr(fmt.Sprintf(`class="%s text-green-600"`, base)) }, }) diff --git a/go/internal/server/ui/templates/active_workers.html b/go/internal/server/ui/templates/active_workers.html index 69df95c..9f61315 100644 --- a/go/internal/server/ui/templates/active_workers.html +++ b/go/internal/server/ui/templates/active_workers.html @@ -1,5 +1,5 @@ {{define "active-workers"}} -
+
@@ -56,8 +56,13 @@

Active Fleet Status< {{end}}

-
{{.ID}}
-
{{.WorkerType}} + +
{{.WorkerType}}
@@ -86,15 +91,16 @@

Active Fleet Status< {{end}} - {{if .LastKps.Valid}} + {{if gt (float64 .LastKps) 0.0 }}
{{printf "%.1f" - .LastKps.Float64}} k/s
+ (float64 .LastKps)}} k/s

{{else}}
---
{{end}} -
{{.LastSeen.Format "15:04:05"}} UTC
+
{{.LastSeen.UTC.Format "15:04:05"}} UTC +
{{else}} diff --git a/go/internal/server/ui/templates/base.html b/go/internal/server/ui/templates/base.html index 411e48a..5597263 100644 --- a/go/internal/server/ui/templates/base.html +++ b/go/internal/server/ui/templates/base.html @@ -110,7 +110,7 @@
-
+
{{template "content" .}}
@@ -129,6 +129,7 @@
+ {{block "scripts" .}}{{end}} diff --git a/go/internal/server/ui/templates/daily.html b/go/internal/server/ui/templates/daily.html index 3b5f265..1c17a53 100644 --- a/go/internal/server/ui/templates/daily.html +++ b/go/internal/server/ui/templates/daily.html @@ -9,13 +9,7 @@ {{end}} {{define "daily-content"}} - - - +

Daily Performance

@@ -52,9 +46,13 @@

View Apply Filter {{if .WorkerID}} -
+
+ + View Worker Profile → +
@@ -66,7 +64,7 @@

View class="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex flex-col justify-between transition hover:shadow-md">

Total Keys (7d)

-

{{.TotalKeys7Days}}

@@ -102,7 +100,7 @@

Batch

Daily - Volume Trend

+ Volume Trend (UTC Time)

@@ -144,7 +142,7 @@

Daily Log {{printf "%.0f" .TotalKeysScanned}} - + {{printf "%.0f" .TotalErrors}} {{else}} @@ -161,16 +159,8 @@

Daily Log {{end}} \ No newline at end of file diff --git a/go/internal/server/ui/templates/leaderboard.html b/go/internal/server/ui/templates/leaderboard.html index a515d93..b0bf3d4 100644 --- a/go/internal/server/ui/templates/leaderboard.html +++ b/go/internal/server/ui/templates/leaderboard.html @@ -89,13 +89,16 @@

Lifetime
- {{.Label}} + + {{.Label}} + {{printf "%.1f" .Pct}}%
{{.Value}} keys + {{dataAttr "value" .Value}}>{{.Value}} keys

{{end}} @@ -140,7 +143,10 @@

Lifetime
-
{{.WorkerID}}
+ + {{.WorkerID}} +
@@ -149,7 +155,7 @@

Lifetime + {{dataAttr "value" .TotalKeysScanned}}> {{.TotalKeysScanned}} diff --git a/go/internal/server/ui/templates/monthly.html b/go/internal/server/ui/templates/monthly.html index 38b31a9..b9fe861 100644 --- a/go/internal/server/ui/templates/monthly.html +++ b/go/internal/server/ui/templates/monthly.html @@ -9,12 +9,7 @@ {{end}} {{define "monthly-content"}} - - - +

Monthly Performance

@@ -51,9 +46,13 @@

View Apply Filter {{if .WorkerID}} -
+
+ + View Worker Profile → +
@@ -78,7 +77,7 @@

Best class="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex flex-col justify-between transition hover:shadow-md">

Total Volume

-

{{.TotalKeysScanned}}

@@ -140,7 +139,7 @@

Monthly Ag .TotalKeysScanned.Float64}} {{printf "%.1f" .KeysPerSecondAvg.Float64}} k/s - + {{printf "%.0f" .TotalErrors.Float64}} @@ -158,16 +157,11 @@

Monthly Ag +{{end}} + +{{define "worker-content"}} + + + +
+
+

Worker Profile

+

Exploring {{.Worker.ID}} ({{.Worker.WorkerType}})

+
+ +
+ + +
+
+ +
+

Configuration

+
+
+ ID + {{.Worker.ID}} +
+
+ Device Type + {{.Worker.WorkerType}} +
+
+ Last Seen + {{.Worker.LastSeen.UTC.Format "2006-01-02 15:04:05"}} + UTC +
+
+
+ + +
+
+
+

+ Lifetime Volume +

+

{{formatCount + .Worker.TotalKeysScanned.Int64}} +

+

Total Keys Scanned +

+
+
+

+ Avg Throughput +

+

+ {{printf "%.1f" (float64 .LifetimeStats.KeysPerSecondAvg)}} +

+

Keys per Second +

+
+
+
+
+ + {{if .Winners}} +
+

+ 🏆 Results Found +

+
+ {{range .Winners}} +
+

+ {{.Address}} +

+
+ {{end}} +
+
+ {{end}} + + +
+

Real-Time + Throughput (UTC Time)

+
+ + +
+ + +
+

Historical Volume (7 Days)

+
+ {{range .ChartPoints}} +
+
+
+
+
+ {{.Date}} +
+ {{end}} +
+
+ + +
+
+

Recent Activity Logs

+
+
+ + + + + + + + + + + + + {{range .History}} + + + + + + + + + {{else}} + + + + {{end}} + +
Time (UTC)PrefixNonce RangeSizeKPSStatus
{{.FinishedAt.UTC.Format "15:04:05"}} UTC + {{truncateHex .Prefix28}} + 0x{{printf "%08x" (int .NonceStart)}} - 0x{{printf "%08x" (int .NonceEnd)}} + {{formatCount (int .BatchSize)}} + {{printf + "%.1f" + (float64 .KeysPerSecond)}} + {{if .ErrorMessage.Valid}}ERROR{{else}}SUCCESS{{end}} +
+ No recent activity logged
+
+
+
+{{end}} \ No newline at end of file diff --git a/go/internal/server/ui/templates/workers.html b/go/internal/server/ui/templates/workers.html index dcfef22..515f13d 100644 --- a/go/internal/server/ui/templates/workers.html +++ b/go/internal/server/ui/templates/workers.html @@ -23,7 +23,10 @@

Connected Worke
-

{{.ID}}

+ + {{.ID}} +

{{.WorkerType}} @@ -37,7 +40,7 @@

Connected Worke -

Last Activity: {{.LastSeen.Format "2006-01-02 15:04:05"}} UTC

+

Last Activity: {{.LastSeen.UTC.Format "2006-01-02 15:04:05"}} UTC

@@ -53,7 +56,7 @@

Connected Worke

- Stats diff --git a/go/internal/server/ui_handlers.go b/go/internal/server/ui_handlers.go index 1d0fe2e..941c84f 100644 --- a/go/internal/server/ui_handlers.go +++ b/go/internal/server/ui_handlers.go @@ -24,13 +24,21 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { activeWorkers, _ := q.GetActiveWorkerDetails(ctx) prefixProgress, _ := q.GetPrefixProgress(ctx) + // Fetch last 10 minutes of global history for initial chart state + recentHistory, _ := q.GetRecentWorkerHistory(ctx, database.GetRecentWorkerHistoryParams{ + Column1: sql.NullString{String: "600", Valid: true}, // 10 minutes + Limit: 1000, + }) + tmpl := "index.html" data := map[string]any{ "CurrentPath": path, "ActiveWorkers": activeWorkers, "PrefixProgress": prefixProgress, + "RecentHistory": recentHistory, "TotalWorkers": stats.TotalWorkers, "ActiveWorkerCount": stats.ActiveWorkers, + "ActiveWorkersList": activeWorkers, "TotalKeysScanned": stats.TotalKeysScanned, "CompletedJobCount": stats.CompletedBatches, "ProcessingJobCount": stats.ProcessingBatches, @@ -320,6 +328,77 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { _ = s.renderer.RenderFragment(w, "leaderboard.html", "leaderboard-content", data) return } + case strings.HasPrefix(path, "/dashboard/workers/"): + workerID := strings.TrimPrefix(path, "/dashboard/workers/") + worker, err := q.GetWorkerByID(ctx, workerID) + if err == nil { + tmpl = "worker_details.html" + winners, _ := q.GetResultsByWorker(ctx, workerID) + historyLogs, _ := q.GetWorkerHistoryLogs(ctx, database.GetWorkerHistoryLogsParams{ + WorkerID: workerID, + Limit: 20, + }) + + // Unified lifetime stats + lifetime, _ := q.GetWorkerLifetimeStats(ctx, workerID) + data["LifetimeStats"] = lifetime + + // Find if active + activeDetails, _ := q.GetActiveWorkerDetails(ctx) + for _, ad := range activeDetails { + if ad.ID == workerID { + data["ActiveWorker"] = ad + break + } + } + + // Fill chart points (last 7 days - simplified for MVP) + type chartPoint struct { + Date string + Keys int64 + } + var chartPoints []chartPoint + // Daily stats for THIS worker from the table + sinceDate7 := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02") + daily, _ := q.GetWorkerDailyStats(ctx, database.GetWorkerDailyStatsParams{ + WorkerID: workerID, + SinceDate: sinceDate7, + }) + + // Map for easy access + statsMap := make(map[string]int64) + for _, s := range daily { + statsMap[s.StatsDate] = int64(s.TotalKeysScanned.Float64) + } + + var maxKeys int64 + for i := 6; i >= 0; i-- { + d := time.Now().UTC().AddDate(0, 0, -i) + dStr := d.Format("2006-01-02") + keys := statsMap[dStr] + if keys > maxKeys { + maxKeys = keys + } + chartPoints = append(chartPoints, chartPoint{ + Date: d.Format("01-02"), + Keys: keys, + }) + } + + data["Worker"] = worker + data["Winners"] = winners + data["History"] = historyLogs + data["ChartPoints"] = chartPoints + data["MaxKeys"] = maxKeys + data["CurrentPath"] = r.URL.Path + + if r.Header.Get("HX-Request") == "true" { + _ = s.renderer.RenderFragment(w, "worker_details.html", "worker-content", data) + return + } + } else { + tmpl = "index.html" + } case strings.HasPrefix(path, "/dashboard/prefixes/"): prefixStr := strings.TrimPrefix(path, "/dashboard/prefixes/") prefixStr = strings.TrimPrefix(prefixStr, "0x")