Add activity log system for provisioning lifecycle visibility
build-and-push / test (push) Failing after 32s
build-and-push / build-and-push (push) Has been skipped

Hosts stuck in states like pxe_ready had zero visibility into why.
This adds a persistent activity log that records every meaningful
step (state transitions, PXE events, cluster join stages, failures)
and surfaces it on the host detail page with live SSE updates.
Includes a stuck-detection warning banner when a host sits in
pxe_ready for >10 minutes with no iPXE request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 23:30:21 -04:00
parent c3a1cf99f9
commit a6603b463f
12 changed files with 209 additions and 12 deletions
+28 -2
View File
@@ -5,6 +5,7 @@ import (
"html"
"net/http"
"strings"
"time"
"provisioning/internal/model"
)
@@ -83,7 +84,7 @@ func hostFormPage(types []string, errMsg string, prefill *model.Host) string {
`, errHTML, hostname, mac, opts.String(), notes))
}
func hostDetailPage(h *model.Host, ops []model.Operation) string {
func hostDetailPage(h *model.Host, ops []model.Operation, activity []model.ActivityEntry) string {
stateClass := stateColor(h.State)
led := ledClass(h.State)
canRebuild := h.State == model.StateRegistered || h.State == model.StateReady || h.State == model.StateFailed
@@ -113,12 +114,29 @@ func hostDetailPage(h *model.Host, ops []model.Operation) string {
ip = "—"
}
var stuckWarning string
if h.State == model.StatePXEReady && time.Since(h.UpdatedAt) > 10*time.Minute {
mins := int(time.Since(h.UpdatedAt).Minutes())
stuckWarning = fmt.Sprintf(`<div class="stuck-warning">Host has been in PXE_READY for %d minutes with no iPXE request. This usually means the host failed to PXE boot — check secure boot settings, network connectivity, and BIOS boot order.</div>`, mins)
}
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(h.Hostname, 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>
%s
<div class="panel">
<table class="detail-table">
<tr><th>MAC</th><td>%s</td></tr>
@@ -135,7 +153,15 @@ func hostDetailPage(h *model.Host, ops []model.Operation) string {
<tbody>%s</tbody>
</table>
</div>
`, led, html.EscapeString(h.Hostname), stateClass, h.State, h.MAC, h.ServerType, ip, html.EscapeString(h.Notes), actions.String(), opsHTML.String()))
<h3>Activity Log</h3>
<div class="panel">
<div class="activity-log" id="activity-log" data-host-id="%d">%s</div>
</div>
`, led, html.EscapeString(h.Hostname), stateClass, h.State,
stuckWarning,
h.MAC, h.ServerType, ip, html.EscapeString(h.Notes), actions.String(),
opsHTML.String(),
h.ID, activityHTML.String()))
}
func imagesPage(images []model.Image) string {