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');