ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
CI / Lint + build + test (push) Successful in 1m26s
Release / release (push) Successful in 6m47s

Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.

Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.

Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 19:00:11 -04:00
parent 5c00edd7b6
commit f79fe0f0db
38 changed files with 3972 additions and 936 deletions
+72 -12
View File
@@ -28,6 +28,7 @@ type UI struct {
Hosts *store.Hosts
Runs *store.Runs
Stages *store.Stages
SubSteps *store.SubSteps
SpecDiffs *store.SpecDiffs
Artifacts *store.Artifacts
EventHub *events.Hub
@@ -118,7 +119,16 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad host id", http.StatusBadRequest)
return
}
data, err := u.LoadHostDetailData(r.Context(), id)
// Optional ?run=N: select a specific past run instead of the latest.
// Rejected runs (bad parse, wrong host) fall back to latest silently
// so a stale bookmark doesn't 404.
var selectedRunID int64
if q := r.URL.Query().Get("run"); q != "" {
if parsed, err := strconv.ParseInt(q, 10, 64); err == nil {
selectedRunID = parsed
}
}
data, err := u.LoadHostDetailData(r.Context(), id, selectedRunID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
@@ -139,7 +149,12 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
// diffs, replay, and tile enrichment are fail-soft (empty on error) —
// mirrors the original inline behaviour so a transient DB hiccup on one
// relation doesn't blank the whole page.
func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.HostDetailData, error) {
//
// selectedRunID == 0 means "use the latest run". A positive value picks
// a specific past run for the hosts/{id}?run=N history-sidebar navigation;
// if that run doesn't exist or belongs to another host we fall back to
// the latest so a stale URL doesn't error out.
func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64, selectedRunID int64) (templates.HostDetailData, error) {
host, err := u.Hosts.Get(ctx, hostID)
if err != nil {
return templates.HostDetailData{}, err
@@ -148,29 +163,74 @@ func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.Ho
if err != nil {
return templates.HostDetailData{}, err
}
// Resolve the viewed run: selectedRunID wins when it matches this
// host; otherwise fall back to latest. A run that belongs to a
// different host is silently ignored — no operator action should be
// able to render another host's run under this page.
viewed := latest
if selectedRunID > 0 && u.Runs != nil {
if r, err := u.Runs.Get(ctx, selectedRunID); err == nil && r != nil && r.HostID == hostID {
viewed = r
}
}
var stages []model.Stage
var diffs []model.SpecDiff
if latest != nil {
var subSteps []model.SubStep
if viewed != nil {
if u.Stages != nil {
stages, _ = u.Stages.ListForRun(ctx, latest.ID)
stages, _ = u.Stages.ListForRun(ctx, viewed.ID)
}
if u.SpecDiffs != nil {
diffs, _ = u.SpecDiffs.ListForRun(ctx, latest.ID)
diffs, _ = u.SpecDiffs.ListForRun(ctx, viewed.ID)
}
if u.SubSteps != nil {
subSteps, _ = u.SubSteps.ListForRun(ctx, viewed.ID)
}
}
t := u.Tiles.Build(ctx, *host, latest)
// Sidebar: last 20 runs for this host, newest first. Fail-soft so a
// transient DB error doesn't blank the whole page.
var history []model.Run
if u.Runs != nil {
history, _ = u.Runs.ListForHost(ctx, hostID, 20)
}
t := u.Tiles.Build(ctx, *host, viewed)
replay := ""
if latest != nil && u.Logs != nil {
replay = u.Logs.Replay(latest.ID)
replayByStage := map[string]string{}
if viewed != nil && u.Logs != nil {
replay = u.Logs.Replay(viewed.ID)
replayByStage = u.Logs.ReplayByStage(viewed.ID)
}
return templates.HostDetailData{
Tile: t,
Stages: stages,
SpecDiffs: diffs,
LogReplay: replay,
Tile: t,
Stages: stages,
SpecDiffs: diffs,
SubSteps: subSteps,
History: history,
DefaultStepStage: pickDefaultStep(stages),
LogReplay: replay,
LogReplayByStage: replayByStage,
}, nil
}
// pickDefaultStep chooses which stage the detail page opens expanded by
// default. Rule: running → first-failed → Reporting. The operator is
// almost always most interested in the thing currently happening (or
// the thing that just failed); Reporting is the sensible terminal fallback
// because it's where the report link lives.
func pickDefaultStep(stages []model.Stage) string {
for _, s := range stages {
if s.State == model.StageRunning {
return s.Name
}
}
for _, s := range stages {
if s.State == model.StageFailed {
return s.Name
}
}
return "Reporting"
}
// StartRun creates a new Run for the host, issues an agent token, and
// transitions Registered→Queued. The dispatcher goroutine picks it up
// on its next tick; the happy path is heartbeat-driven (the reporter's