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"}} -