Add job detail page with activity log and cancel support
build-and-push / test (push) Successful in 34s
build-and-push / build-and-push (push) Successful in 1m8s

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 10:37:18 -04:00
parent 5ff1cff7d4
commit 1317ff6369
9 changed files with 275 additions and 5 deletions
+94 -2
View File
@@ -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(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
op.Kind, op.State, op.StartedAt.Format("2006-01-02 15:04"), duration, errCell))
opsHTML.WriteString(fmt.Sprintf(`<tr><td><a href="/ops/%d">%s</a></td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
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(`<tr><th>Error</th><td style="color:var(--red)">%s</td></tr>`, html.EscapeString(op.ErrorMessage))
}
hostname := "unknown"
hostLink := ""
if host != nil {
hostname = html.EscapeString(host.Hostname)
hostLink = fmt.Sprintf(`<a href="/hosts/%d" style="color:var(--accent);text-decoration:none">%s</a>`, host.ID, hostname)
}
var cancelBtn string
if op.State == model.OpActive {
cancelBtn = fmt.Sprintf(`<div class="actions"><form method="POST" action="/ops/%d/cancel" class="inline" onsubmit="return confirm('Cancel this operation? The host will be marked as failed.')"><button class="btn btn-danger">Cancel Operation</button></form></div>`, op.ID)
}
var activityHTML strings.Builder
for _, e := range activity {
activityHTML.WriteString(fmt.Sprintf(
`<div class="log-entry log-%s"><span class="log-time">%s</span><span class="log-source">%s</span><span class="log-msg">%s</span></div>`,
e.Level, e.CreatedAt.Format("15:04"), html.EscapeString(e.Source), html.EscapeString(e.Message)))
}
if len(activity) == 0 {
activityHTML.WriteString(`<p class="empty">No activity recorded yet.</p>`)
}
return layout(string(op.Kind), fmt.Sprintf(`
<div class="host-header">
<span class="led led-lg %s"></span>
<h2 style="margin-bottom:0">%s</h2>
<span class="badge %s">%s</span>
</div>
<div class="panel">
<table class="detail-table">
<tr><th>Kind</th><td>%s</td></tr>
<tr><th>State</th><td>%s</td></tr>
<tr><th>Host</th><td>%s</td></tr>
<tr><th>Started</th><td>%s</td></tr>
<tr><th>Duration</th><td>%s</td></tr>
%s
</table>
%s
</div>
<h3>Activity Log</h3>
<div class="panel">
<div class="activity-log" id="activity-log" data-operation-id="%d">%s</div>
</div>
`, 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(`<!DOCTYPE html>
<html lang="en">
+81
View File
@@ -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)
}
+41
View File
@@ -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