From 1317ff636983ea9cdec6241aa55980fd4e98c75f Mon Sep 17 00:00:00 2001 From: josh Date: Thu, 14 May 2026 10:37:18 -0400 Subject: [PATCH] Add job detail page with activity log and cancel support Operations are now clickable from the host detail page, linking to /ops/{id} which shows the operation info, host link, duration, and activity log filtered to that operation. Active operations can be cancelled, which transitions the host to failed and releases the lock. SSE activity events now include operation_id for real-time filtering. Co-Authored-By: Claude Opus 4.6 --- internal/api/render.go | 96 ++++++++++++++++++++++++++++++++- internal/api/smoke_test.go | 81 ++++++++++++++++++++++++++++ internal/api/ui.go | 41 ++++++++++++++ internal/httpserver/router.go | 2 + internal/orchestrator/runner.go | 4 +- internal/store/activity.go | 25 +++++++++ internal/store/operations.go | 22 ++++++++ internal/web/static/app.css | 2 + internal/web/static/app.js | 7 ++- 9 files changed, 275 insertions(+), 5 deletions(-) diff --git a/internal/api/render.go b/internal/api/render.go index 514e07b..b946a5e 100644 --- a/internal/api/render.go +++ b/internal/api/render.go @@ -105,8 +105,8 @@ func hostDetailPage(h *model.Host, ops []model.Operation, activity []model.Activ if op.ErrorMessage != "" { errCell = html.EscapeString(op.ErrorMessage) } - opsHTML.WriteString(fmt.Sprintf(`%s%s%s%s%s`, - op.Kind, op.State, op.StartedAt.Format("2006-01-02 15:04"), duration, errCell)) + opsHTML.WriteString(fmt.Sprintf(`%s%s%s%s%s`, + op.ID, op.Kind, op.State, op.StartedAt.Format("2006-01-02 15:04"), duration, errCell)) } ip := h.IPAddress @@ -259,6 +259,98 @@ func ledClass(s model.HostState) string { } } +func operationDetailPage(op *model.Operation, host *model.Host, activity []model.ActivityEntry) string { + osClass := opStateColor(op.State) + oLed := opLedClass(op.State) + + duration := "" + if op.CompletedAt != nil { + duration = op.CompletedAt.Sub(op.StartedAt).Truncate(1e9).String() + } else { + duration = time.Since(op.StartedAt).Truncate(1e9).String() + " (running)" + } + + errRow := "" + if op.ErrorMessage != "" { + errRow = fmt.Sprintf(`Error%s`, html.EscapeString(op.ErrorMessage)) + } + + hostname := "unknown" + hostLink := "" + if host != nil { + hostname = html.EscapeString(host.Hostname) + hostLink = fmt.Sprintf(`%s`, host.ID, hostname) + } + + var cancelBtn string + if op.State == model.OpActive { + cancelBtn = fmt.Sprintf(`
`, op.ID) + } + + var activityHTML strings.Builder + for _, e := range activity { + activityHTML.WriteString(fmt.Sprintf( + `
%s%s%s
`, + e.Level, e.CreatedAt.Format("15:04"), html.EscapeString(e.Source), html.EscapeString(e.Message))) + } + if len(activity) == 0 { + activityHTML.WriteString(`

No activity recorded yet.

`) + } + + return layout(string(op.Kind), fmt.Sprintf(` +
+ +

%s

+ %s +
+
+ + + + + + + %s +
Kind%s
State%s
Host%s
Started%s
Duration%s
+ %s +
+

Activity Log

+
+
%s
+
+ `, oLed, html.EscapeString(string(op.Kind)), osClass, op.State, + op.Kind, op.State, hostLink, + op.StartedAt.Format("2006-01-02 15:04:05"), + duration, errRow, cancelBtn, + op.ID, activityHTML.String())) +} + +func opStateColor(s model.OperationState) string { + switch s { + case model.OpActive: + return "state-blue" + case model.OpCompleted: + return "state-green" + case model.OpFailed: + return "state-red" + default: + return "state-grey" + } +} + +func opLedClass(s model.OperationState) string { + switch s { + case model.OpActive: + return "led-blue" + case model.OpCompleted: + return "led-green" + case model.OpFailed: + return "led-red" + default: + return "led-grey" + } +} + func layout(title, body string) string { return fmt.Sprintf(` diff --git a/internal/api/smoke_test.go b/internal/api/smoke_test.go index 09cabd0..7e88da2 100644 --- a/internal/api/smoke_test.go +++ b/internal/api/smoke_test.go @@ -256,6 +256,87 @@ func TestDashboardHTML(t *testing.T) { } } +func TestOperationDetailPage(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + body := `{"hostname":"pve-test-op","mac":"aa:bb:cc:dd:ee:10","server_type":"test-type"}` + resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) + var created model.Host + json.NewDecoder(resp.Body).Decode(&created) + resp.Body.Close() + + // Trigger rebuild to create an operation + resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil) + resp.Body.Close() + + // Get operation detail page + client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, err := client.Get(ts.URL + "/ops/1") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("op detail: got %d, want %d", resp.StatusCode, http.StatusOK) + } + if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Fatalf("content-type = %q", ct) + } + resp.Body.Close() +} + +func TestCancelOperation(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + body := `{"hostname":"pve-test-cancel","mac":"aa:bb:cc:dd:ee:11","server_type":"test-type"}` + resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body)) + var created model.Host + json.NewDecoder(resp.Body).Decode(&created) + resp.Body.Close() + + resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil) + resp.Body.Close() + + // Cancel the operation + client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, err := client.Post(ts.URL+"/ops/1/cancel", "", nil) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("cancel: got %d, want %d", resp.StatusCode, http.StatusSeeOther) + } + resp.Body.Close() + + // Verify host is now failed + resp, _ = http.Get(ts.URL + "/api/hosts/" + itoa(created.ID)) + var host model.Host + json.NewDecoder(resp.Body).Decode(&host) + resp.Body.Close() + if host.State != model.StateFailed { + t.Fatalf("state = %q, want %q", host.State, model.StateFailed) + } +} + +func TestOperationNotFound(t *testing.T) { + ts := newTestServer(t) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/ops/99999") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("op not found: got %d, want %d", resp.StatusCode, http.StatusNotFound) + } + resp.Body.Close() +} + func itoa(i int64) string { return fmt.Sprintf("%d", i) } diff --git a/internal/api/ui.go b/internal/api/ui.go index fd36e8b..18c5c28 100644 --- a/internal/api/ui.go +++ b/internal/api/ui.go @@ -111,6 +111,47 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) { renderHTML(w, hostDetailPage(host, ops, activity)) } +func (u *UI) OperationDetail(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + var id int64 + fmt.Sscanf(idStr, "%d", &id) + + op, err := u.Ops.Get(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.Error(w, "Operation not found", http.StatusNotFound) + return + } + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + host, _ := u.Hosts.Get(r.Context(), op.HostID) + activity, _ := u.Activity.ListByOperation(r.Context(), op.ID, 100) + renderHTML(w, operationDetailPage(op, host, activity)) +} + +func (u *UI) CancelOperation(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + var id int64 + fmt.Sscanf(idStr, "%d", &id) + + op, err := u.Ops.Get(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + http.Error(w, "Operation not found", http.StatusNotFound) + return + } + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if op.State != model.OpActive { + http.Redirect(w, r, fmt.Sprintf("/ops/%d", id), http.StatusSeeOther) + return + } + u.Runner.FailHost(r.Context(), op.HostID, "cancelled by user") + http.Redirect(w, r, fmt.Sprintf("/ops/%d", id), http.StatusSeeOther) +} + func (u *UI) TriggerRebuild(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") var id int64 diff --git a/internal/httpserver/router.go b/internal/httpserver/router.go index 452d149..048ba35 100644 --- a/internal/httpserver/router.go +++ b/internal/httpserver/router.go @@ -45,6 +45,8 @@ func NewRouter(d Deps) http.Handler { r.Get("/hosts/{id}", d.UI.HostDetail) r.Post("/hosts/{id}/rebuild", d.UI.TriggerRebuild) r.Post("/hosts/{id}/delete", d.UI.DeleteHost) + r.Get("/ops/{id}", d.UI.OperationDetail) + r.Post("/ops/{id}/cancel", d.UI.CancelOperation) r.Get("/images", d.UI.ImagesPage) r.Get("/images/new", d.UI.NewImageForm) r.Post("/images/upload", d.UI.UploadImage) diff --git a/internal/orchestrator/runner.go b/internal/orchestrator/runner.go index c987e4e..62706c8 100644 --- a/internal/orchestrator/runner.go +++ b/internal/orchestrator/runner.go @@ -62,7 +62,7 @@ func (r *Runner) LogActivity(ctx context.Context, hostID int64, level model.LogL id, _ := r.Activity.Log(ctx, hostID, opID, level, source, message) r.Hub.Publish(events.Event{ Name: "activity.logged", - Payload: fmt.Sprintf(`{"id":%d,"host_id":%d,"level":"%s","source":"%s","message":"%s","created_at":"%s"}`, - id, hostID, level, source, message, time.Now().UTC().Format(time.RFC3339)), + Payload: fmt.Sprintf(`{"id":%d,"host_id":%d,"operation_id":%d,"level":"%s","source":"%s","message":"%s","created_at":"%s"}`, + id, hostID, opID, level, source, message, time.Now().UTC().Format(time.RFC3339)), }) } diff --git a/internal/store/activity.go b/internal/store/activity.go index b75a74d..d806462 100644 --- a/internal/store/activity.go +++ b/internal/store/activity.go @@ -24,6 +24,31 @@ func (s *Activity) Log(ctx context.Context, hostID, opID int64, level model.LogL return res.LastInsertId() } +func (s *Activity) ListByOperation(ctx context.Context, operationID int64, limit int) ([]model.ActivityEntry, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.DB.QueryContext(ctx, ` + SELECT id, host_id, COALESCE(operation_id, 0), level, message, source, created_at + FROM activity_log WHERE operation_id = ? ORDER BY created_at DESC LIMIT ? + `, operationID, limit) + if err != nil { + return nil, fmt.Errorf("list activity by operation: %w", err) + } + defer rows.Close() + var out []model.ActivityEntry + for rows.Next() { + var e model.ActivityEntry + var createdAt string + if err := rows.Scan(&e.ID, &e.HostID, &e.OperationID, &e.Level, &e.Message, &e.Source, &createdAt); err != nil { + return nil, fmt.Errorf("scan activity: %w", err) + } + e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + out = append(out, e) + } + return out, rows.Err() +} + func (s *Activity) ListByHost(ctx context.Context, hostID int64, limit int) ([]model.ActivityEntry, error) { if limit <= 0 { limit = 50 diff --git a/internal/store/operations.go b/internal/store/operations.go index 748a4ba..a610ca4 100644 --- a/internal/store/operations.go +++ b/internal/store/operations.go @@ -61,6 +61,28 @@ func (s *Operations) ListByHost(ctx context.Context, hostID int64) ([]model.Oper return out, rows.Err() } +func (s *Operations) Get(ctx context.Context, id int64) (*model.Operation, error) { + row := s.DB.QueryRowContext(ctx, ` + SELECT id, host_id, kind, state, COALESCE(image_id, 0), started_at, completed_at, COALESCE(error_message, '') + FROM operations WHERE id = ? + `, id) + var op model.Operation + var startedAt string + var completedAt sql.NullString + if err := row.Scan(&op.ID, &op.HostID, &op.Kind, &op.State, &op.ImageID, &startedAt, &completedAt, &op.ErrorMessage); err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get operation: %w", err) + } + op.StartedAt, _ = time.Parse(time.RFC3339, startedAt) + if completedAt.Valid { + t, _ := time.Parse(time.RFC3339, completedAt.String) + op.CompletedAt = &t + } + return &op, nil +} + func (s *Operations) GetActive(ctx context.Context, hostID int64) (*model.Operation, error) { row := s.DB.QueryRowContext(ctx, ` SELECT id, host_id, kind, state, COALESCE(image_id, 0), started_at, completed_at, COALESCE(error_message, '') diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 3cf7b77..89724c8 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -317,6 +317,8 @@ main { color: var(--text); } .ops-table tr:hover td { background: var(--accent-subtle); } +.ops-table a { color: var(--accent); text-decoration: none; font-weight: 500; } +.ops-table a:hover { text-decoration: underline; } /* === FORMS === */ .form { max-width: 420px; } diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 739131c..a5585e6 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -16,7 +16,12 @@ var logDiv = document.getElementById('activity-log'); if (!logDiv) return; var hostId = logDiv.getAttribute('data-host-id'); - if (String(data.host_id) !== hostId) return; + var opId = logDiv.getAttribute('data-operation-id'); + if (opId) { + if (String(data.operation_id) !== opId) return; + } else if (hostId) { + if (String(data.host_id) !== hostId) return; + } else { return; } var empty = logDiv.querySelector('.empty'); if (empty) empty.remove(); var entry = document.createElement('div');