ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
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:
+72
-12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user