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 <noreply@anthropic.com>
This commit is contained in:
+94
-2
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user