ui: split /hosts/{id} into host page + /runs/{runID} run page
Host page owns host metadata, full runs table with per-row stage strip, in-flight banner, and empty-state CTA. Run page owns pipeline, active step, logs, sub-steps, spec diffs, and hold banner with a breadcrumb back to the host. Dashboard tile reverts to host-only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+120
-73
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -107,28 +108,19 @@ func (u *UI) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
_ = templates.Dashboard(tiles).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
// HostDetail renders the per-host page: breadcrumb, summary, pipeline
|
||||
// timeline, hold card, action row, spec diffs, log pane, meta. Same
|
||||
// enrichment path as Dashboard for tile data; additionally reads stage
|
||||
// rows + spec diffs for the latest run to populate the timeline and
|
||||
// diff list.
|
||||
func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// HostPage renders /hosts/{id}: summary + actions + in-flight banner +
|
||||
// runs table. Run-level detail (pipeline, logs, sub-steps, spec diffs,
|
||||
// hold banner) lives on /runs/{runID}. The split keeps host-scoped and
|
||||
// run-scoped work on distinct URLs so permalinks don't wander onto
|
||||
// whichever run happens to be active.
|
||||
func (u *UI) HostPage(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
data, err := u.LoadHostPageData(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
@@ -137,78 +129,129 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = templates.HostDetail(data).Render(r.Context(), w)
|
||||
_ = templates.HostPage(data).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
// LoadHostDetailData assembles the HostDetailData payload for hostID —
|
||||
// the same bundle the initial GET renders. Also used by the orchestrator's
|
||||
// PublishHostDetail path so the live SSE fragments render from identical
|
||||
// inputs as the initial page, avoiding drift between reload-rendered and
|
||||
// pushed HTML. Returns store.ErrNotFound if the host doesn't exist; all
|
||||
// other store errors are surfaced to the caller. Sub-queries for stages,
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
// LoadHostPageData assembles the HostPageData payload for hostID — host
|
||||
// metadata, the full newest-first runs list, the currently non-terminal
|
||||
// run (if any) for the in-flight banner, and a per-run stages map so
|
||||
// the runs table can paint its compact stage-strips without re-querying
|
||||
// inside the template. Returns store.ErrNotFound when the host doesn't
|
||||
// exist; other store errors are surfaced. Stage lookups are fail-soft:
|
||||
// a transient DB error on one run's stages yields an empty strip for
|
||||
// that row rather than blanking the whole page.
|
||||
func (u *UI) LoadHostPageData(ctx context.Context, hostID int64) (templates.HostPageData, error) {
|
||||
host, err := u.Hosts.Get(ctx, hostID)
|
||||
if err != nil {
|
||||
return templates.HostDetailData{}, err
|
||||
return templates.HostPageData{}, err
|
||||
}
|
||||
latest, err := u.Runs.LatestForHost(ctx, hostID)
|
||||
if err != nil {
|
||||
return templates.HostDetailData{}, err
|
||||
var runs []model.Run
|
||||
if u.Runs != nil {
|
||||
runs, _ = u.Runs.ListForHostAll(ctx, hostID)
|
||||
}
|
||||
// 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 active *model.Run
|
||||
for i := range runs {
|
||||
if !runs[i].State.IsTerminal() {
|
||||
active = &runs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
runStages := make(map[int64][]model.Stage, len(runs))
|
||||
if u.Stages != nil {
|
||||
for _, r := range runs {
|
||||
if stages, err := u.Stages.ListForRun(ctx, r.ID); err == nil {
|
||||
runStages[r.ID] = stages
|
||||
}
|
||||
}
|
||||
}
|
||||
return templates.HostPageData{
|
||||
Host: *host,
|
||||
LastSeenAt: host.LastSeenAt,
|
||||
Runs: runs,
|
||||
ActiveRun: active,
|
||||
RunStages: runStages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RunPage renders /runs/{runID}: breadcrumb, run header, hold banner,
|
||||
// pipeline, per-stage active-step panels, and spec diffs. Host metadata
|
||||
// is resolved from run.HostID for the breadcrumb and for action POST
|
||||
// targets (cancel/override still live under /hosts/{hostID}/...).
|
||||
func (u *UI) RunPage(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "runID")
|
||||
runID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad run id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := u.LoadRunPageData(r.Context(), runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = templates.RunPage(data).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
// LoadRunPageData assembles the RunPageData payload for runID. Resolves
|
||||
// the owning host, then reads stages, sub-steps, spec diffs, and log
|
||||
// replay. Returns store.ErrNotFound when the run or host is gone. The
|
||||
// orchestrator's PublishRunPage path uses the same loader so SSE fragments
|
||||
// render from identical inputs as the initial GET.
|
||||
func (u *UI) LoadRunPageData(ctx context.Context, runID int64) (templates.RunPageData, error) {
|
||||
if u.Runs == nil {
|
||||
return templates.RunPageData{}, store.ErrNotFound
|
||||
}
|
||||
run, err := u.Runs.Get(ctx, runID)
|
||||
if err != nil {
|
||||
return templates.RunPageData{}, err
|
||||
}
|
||||
if run == nil {
|
||||
return templates.RunPageData{}, store.ErrNotFound
|
||||
}
|
||||
host, err := u.Hosts.Get(ctx, run.HostID)
|
||||
if err != nil {
|
||||
return templates.RunPageData{}, err
|
||||
}
|
||||
var stages []model.Stage
|
||||
var diffs []model.SpecDiff
|
||||
var subSteps []model.SubStep
|
||||
if viewed != nil {
|
||||
if u.Stages != nil {
|
||||
stages, _ = u.Stages.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
if u.SpecDiffs != nil {
|
||||
diffs, _ = u.SpecDiffs.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
if u.SubSteps != nil {
|
||||
subSteps, _ = u.SubSteps.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
var diffs []model.SpecDiff
|
||||
if u.Stages != nil {
|
||||
stages, _ = u.Stages.ListForRun(ctx, runID)
|
||||
}
|
||||
// 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)
|
||||
if u.SubSteps != nil {
|
||||
subSteps, _ = u.SubSteps.ListForRun(ctx, runID)
|
||||
}
|
||||
if u.SpecDiffs != nil {
|
||||
diffs, _ = u.SpecDiffs.ListForRun(ctx, runID)
|
||||
}
|
||||
t := u.Tiles.Build(ctx, *host, viewed)
|
||||
replay := ""
|
||||
replayByStage := map[string]string{}
|
||||
if viewed != nil && u.Logs != nil {
|
||||
replay = u.Logs.Replay(viewed.ID)
|
||||
replayByStage = u.Logs.ReplayByStage(viewed.ID)
|
||||
if u.Logs != nil {
|
||||
replayByStage = u.Logs.ReplayByStage(runID)
|
||||
}
|
||||
return templates.HostDetailData{
|
||||
Tile: t,
|
||||
// Critical-diff count + hold-key path reuse the tile enricher so the
|
||||
// run header shows the same numbers the dashboard tile + runs-table
|
||||
// row show. Fail-soft if tiles isn't wired (test setups can skip it).
|
||||
critical := 0
|
||||
holdKeyPath := ""
|
||||
if u.Tiles != nil {
|
||||
t := u.Tiles.Build(ctx, *host, run)
|
||||
critical = t.SpecDiffCritical
|
||||
holdKeyPath = t.HoldKeyPath
|
||||
}
|
||||
return templates.RunPageData{
|
||||
Host: *host,
|
||||
Run: *run,
|
||||
Stages: stages,
|
||||
SpecDiffs: diffs,
|
||||
SubSteps: subSteps,
|
||||
History: history,
|
||||
SpecDiffs: diffs,
|
||||
DefaultStepStage: pickDefaultStep(stages),
|
||||
LogReplay: replay,
|
||||
LogReplayByStage: replayByStage,
|
||||
HoldKeyPath: holdKeyPath,
|
||||
SpecDiffCritical: critical,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -285,7 +328,9 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("ui: created run %d for host %d (state=Queued)", runID, hostID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
// Send the operator straight to the new run — the button they clicked
|
||||
// was "Start vetting", the thing they want next is to watch it.
|
||||
http.Redirect(w, r, fmt.Sprintf("/runs/%d", runID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -542,7 +587,9 @@ func (u *UI) OverrideWipeStorage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "override: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
// Operator was on /runs/{latest.ID} when they clicked — land them
|
||||
// back there so they can see the override take effect.
|
||||
http.Redirect(w, r, fmt.Sprintf("/runs/%d", latest.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// CancelRun halts an in-flight run. Transitions the run to
|
||||
@@ -571,7 +618,7 @@ func (u *UI) CancelRun(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("ui: cancelled run %d for host %d", latest.ID, hostID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
http.Redirect(w, r, fmt.Sprintf("/runs/%d", latest.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user