Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/tasks/done/P10-T120.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# P10-T120: Implement "Error Log" and alert notifications

**Phase:** P10 - Dashboard & Monitoring UI
**Status:** In Progress
**Priority:** Medium
**Dependencies:** [P10-T050]
**Estimated Effort:** Medium

---

## Description

Add a real-time error monitoring panel and "toast" style notifications for critical events, such as a worker losing connection or an invalid checkpoint submission.

## Current Status & Blockers

- [ ] **BLOCKER:** Auto-close and Manual close on toasts are NOT working.
- [ ] Notifications appear but remain permanently on the screen, cluttering the UI.
- [ ] Multiple attempts were made using JavaScript `setTimeout`, `MutationObserver`, and CSS `onanimationend`, but the toasts persist due to internal HTMX/WS update logic or DOM state issues.
- [ ] The feature is considered "NOT EXECUTED" as it fails the core UX requirement of being non-persistent.

## Acceptance Criteria

- [ ] Create a "Recent Errors" sidebar or sub-tab.
- [x] Broadcast errors via the WebSocket hub when they occur in the API handlers.
- [ ] Use a toast/alert notification component for high-severity issues (Persistent bug: they don't close).
- [x] Log errors on the dashboard with a timestamp and worker ID.
- [x] Add a "Clear Log" button (client-side only or persisted).

## Implementation Notes

- Use the HTMX "out-of-band" (OOB) swap feature to push notification HTML into a fixed container from any WebSocket message.
- Style alerts using standard Tailwind alert colors (`bg-red-500`, `bg-yellow-500`).

## Testing

- Simulate worker errors (e.g., malformed JSON).
- Verify the alert appears instantly in the browser.
- Verify the log persists as long as the page is not refreshed.

## References

- [htmx.org - OOB swaps](https://htmx.org/attributes/hx-swap-oob/)
3 changes: 3 additions & 0 deletions go/internal/server/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
Expand Down Expand Up @@ -93,13 +94,15 @@ func (s *Server) handleJobCheckpoint(w http.ResponseWriter, r *http.Request) {
if job.Status != "processing" {
// #nosec G706: logging raw body for debugging, even on decode failure
log.Printf("checkpoint failed: job %d status is %s, expected processing. Worker: %q", id, job.Status, req.WorkerID)
s.BroadcastEvent(req.WorkerID, "Invalid Status", fmt.Sprintf("Checkpoint rejected for Job %d: status is %s", id, job.Status), "error")
// Return 410 Gone to signal the worker to stop this job
http.Error(w, "job no longer active", http.StatusGone)
return
}
if !job.WorkerID.Valid || job.WorkerID.String != req.WorkerID {
// #nosec G706: logging raw body for debugging, even on decode failure
log.Printf("checkpoint failed: job %d owned by %v, but checkpoint from %q", id, job.WorkerID.String, req.WorkerID)
s.BroadcastEvent(req.WorkerID, "Forbidden Job", fmt.Sprintf("Worker mismatch for Job %d", id), "error")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
Expand Down
4 changes: 4 additions & 0 deletions go/internal/server/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
Expand Down Expand Up @@ -129,10 +130,13 @@ func (s *Server) handleJobComplete(w http.ResponseWriter, r *http.Request) {
WorkerID: sql.NullString{String: req.WorkerID, Valid: true},
}
if err := q.CompleteBatch(ctx, params); err != nil {
s.BroadcastEvent(req.WorkerID, "Completion Failure", fmt.Sprintf("Failed to complete job %d: %v", id, err), "error")
http.Error(w, "failed to complete job", http.StatusInternalServerError)
return
}

s.BroadcastEvent(req.WorkerID, "Job Done", fmt.Sprintf("Batch %d completed: scanned %d keys", id, req.KeysScanned), "success")

updated, err := q.GetJobByID(ctx, id)
if err != nil {
http.Error(w, "failed to fetch updated job", http.StatusInternalServerError)
Expand Down
33 changes: 33 additions & 0 deletions go/internal/server/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,39 @@ func (s *Server) Broadcast(message []byte) {
s.hub.broadcast <- message
}

// BroadcastEvent renders a toast notification and adds it to the system log.
func (s *Server) BroadcastEvent(workerID string, title string, message string, eventType string) {
data := struct {
ID int64
Timestamp string
WorkerID string
Title string
Message string
Type string // "info", "warning", "error", "success"
}{
ID: time.Now().UnixNano(),
Timestamp: time.Now().UTC().Format("15:04:05"),
WorkerID: workerID,
Title: title,
Message: message,
Type: eventType,
}

var buf strings.Builder
// Render the toast notification (OOB swap into #notifications)
if err := s.renderer.RenderFragment(&buf, "notifications.html", "toast", data); err != nil {
log.Printf("hub: failed to render toast fragment: %v", err)
}
// Render the log entry (OOB swap into #error-log)
if err := s.renderer.RenderFragment(&buf, "notifications.html", "error-log-entry", data); err != nil {
log.Printf("hub: failed to render error log fragment: %v", err)
}

if buf.Len() > 0 {
s.Broadcast([]byte(buf.String()))
}
}

// broadcastStats is called periodically or when an update happens to broadcast
// refreshed stats to all connected dashboard clients.
func (s *Server) broadcastStats(ctx context.Context) {
Expand Down
3 changes: 3 additions & 0 deletions go/internal/server/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ func (s *Server) handleJobLease(w http.ResponseWriter, r *http.Request) {
ExpiresAt *string `json:"expires_at,omitempty"`
}

p64 := base64.StdEncoding.EncodeToString(job.Prefix28)
s.BroadcastEvent(req.WorkerID, "Lease Request", fmt.Sprintf("Leased job %d (prefix: %s...)", job.ID, p64[:8]), "info")

targets := s.cfg.TargetAddresses
if s.cfg.WinScenario {
// Ensure the winner address is in the targets list for this job
Expand Down
3 changes: 3 additions & 0 deletions go/internal/server/results.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
Expand Down Expand Up @@ -74,10 +75,12 @@ func (s *Server) handleResultSubmit(w http.ResponseWriter, r *http.Request) {
res, err := q.InsertResult(ctx, params)
if err != nil {
log.Printf("failed to insert result from worker %s: %v", req.WorkerID, err)
s.BroadcastEvent(req.WorkerID, "Result Rejected", fmt.Sprintf("Failed to save found address %s", req.Address), "error")
http.Error(w, "failed to insert result", http.StatusInternalServerError)
return
}

s.BroadcastEvent(req.WorkerID, "MATCH FOUND!", fmt.Sprintf("Key found for address %s", req.Address), "success")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(res)
Expand Down
2 changes: 1 addition & 1 deletion go/internal/server/ui/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func (r *TemplateRenderer) loadTemplates() error {
}
if entry.Name() == "base.html" {
layoutFiles = append(layoutFiles, filepath.Join("templates", entry.Name()))
} else if entry.Name() == "fragments.html" || entry.Name() == "active_workers.html" || entry.Name() == "found_results.html" {
} else if entry.Name() == "fragments.html" || entry.Name() == "active_workers.html" || entry.Name() == "found_results.html" || entry.Name() == "notifications.html" {
partialFiles = append(partialFiles, filepath.Join("templates", entry.Name()))
}
}
Expand Down
5 changes: 5 additions & 0 deletions go/internal/server/ui/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@
</div>
</div>
</footer>

<!-- Notification Toast Container (OOB Target) -->
<div id="notifications" class="fixed top-4 right-4 z-50 flex flex-col gap-3 pointer-events-none w-80 md:w-96">
</div>

{{block "scripts" .}}{{end}}
</body>

Expand Down
26 changes: 26 additions & 0 deletions go/internal/server/ui/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,32 @@ <h3 class="text-xs font-black text-gray-400 uppercase tracking-widest">Active Se
{{template "prefix-progress-content" .}}
</div>
</div>

<!-- Recent System Events / Errors -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mt-8 transition-all duration-300">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
<div class="flex items-center space-x-2">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<h3 class="text-sm font-bold text-gray-900 uppercase tracking-widest">Recent Events & Errors</h3>
</div>
<button type="button"
onclick="document.getElementById('error-log').innerHTML = '<div class=\'flex flex-col items-center justify-center p-8 text-gray-400\'><p class=\'text-[11px] font-bold uppercase tracking-tighter\'>No events logged yet</p><p class=\'text-[10px] lowercase tracking-wide mt-1\'>Real-time alerts will appear here</p></div>'"
class="text-[10px] font-bold uppercase tracking-widest text-blue-500 hover:text-blue-700 transition">
Clear Log
</button>
</div>
<div id="error-log" class="max-h-80 overflow-y-auto min-h-[100px] bg-white">
<!-- Errors will be prepended here via WebSocket OOB swap -->
<div class="flex flex-col items-center justify-center p-8 text-gray-400">
<p class="text-[11px] font-bold uppercase tracking-tighter">No events logged yet</p>
<p class="text-[10px] lowercase tracking-wide mt-1">Real-time alerts will appear here</p>
</div>
</div>
</div>
</div>

<script>
Expand Down
72 changes: 72 additions & 0 deletions go/internal/server/ui/templates/notifications.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{{define "toast"}}
<div id="toast-{{ .ID }}" hx-swap-oob="beforeend:#notifications"
class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-300 transform translate-y-0 opacity-100"
role="alert">
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
{{ if eq .Type "error" }}
<svg class="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
{{ else if eq .Type "warning" }}
<svg class="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
{{ else if eq .Type "info" }}
<svg class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
{{ else }}
<svg class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ end }}
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium text-gray-900">{{ .Title }}</p>
<p class="mt-1 text-sm text-gray-500">{{ .Message }}</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<button type="button" onclick="this.closest('[role=alert]').remove()"
class="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<span class="sr-only">Close</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
</div>
<script>setTimeout(() => { document.getElementById('toast-{{ .ID }}')?.remove() }, 5000)</script>
</div>
{{end}}

{{define "error-log-entry"}}
<div id="error-log" hx-swap-oob="afterbegin">
<div class="flex items-start space-x-3 p-3 border-b border-gray-100 last:border-0 hover:bg-gray-50 transition">
<div class="flex-shrink-0 mt-0.5">
<span class="flex h-2 w-2 rounded-full {{ if eq .Type " error" }}bg-red-500{{ else if eq .Type "warning"
}}bg-yellow-500{{ else if eq .Type "info" }}bg-blue-500{{ else }}bg-green-500{{ end }}"></span>
</div>
<div class="flex-grow min-w-0">
<div class="flex justify-between items-center mb-1">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-400">{{ .Timestamp }}</span>
<span class="text-[10px] font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">{{ .WorkerID
}}</span>
</div>
<p class="text-xs font-semibold text-gray-800 leading-snug">{{ .Title }}</p>
<p class="text-[11px] text-gray-500 truncate mt-0.5">{{ .Message }}</p>
</div>
</div>
</div>
{{end}}