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(`
+
+
+
+ | Kind | %s |
+ | State | %s |
+ | Host | %s |
+ | Started | %s |
+ | Duration | %s |
+ %s
+
+ %s
+
+ Activity Log
+
+ `, 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');