Click a tile to open /hosts/{id} — the canonical control surface per
host. Timeline renders every pre-stage, stage, and terminal node in
order, with the current one pulsing, failed ones flagged, and
downstream ones dimmed as skipped. Detail page shows summary, hold
card (when holding), all action buttons, spec diffs, a full-height
log pane, and a collapsed expected-spec YAML.
Tile slims to name, last-seen, status, and one primary action; a
CSS-overlay <a> makes the whole card clickable while buttons stay
receptive via z-index.
Runner.publishTileUpdate now also emits pipeline-{runID} fragments,
and CompleteStage wraps Stages.CompleteByName so stage completions
advance the timeline live — without this the dots only moved on
state transitions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -408,7 +408,7 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
if len(body.Summary) > 0 {
|
||||
summaryJSON = string(body.Summary)
|
||||
}
|
||||
if err := a.Stages.CompleteByName(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
|
||||
if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
|
||||
http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -544,7 +544,7 @@ func (a *Agent) resolveSpecValidate(r *http.Request, runID int64) {
|
||||
"critical": critical,
|
||||
})
|
||||
if critical > 0 {
|
||||
_ = a.Stages.CompleteByName(r.Context(), runID, "SpecValidate", model.StageFailed, string(summaryBuf))
|
||||
_ = a.Runner.CompleteStage(r.Context(), runID, "SpecValidate", model.StageFailed, string(summaryBuf))
|
||||
_ = a.Runs.SetFailedStage(r.Context(), runID, "SpecValidate")
|
||||
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
|
||||
log.Printf("specvalidate: failed-transition: %v", err)
|
||||
@@ -561,7 +561,7 @@ func (a *Agent) resolveSpecValidate(r *http.Request, runID int64) {
|
||||
URL: a.runLinkURL(runID),
|
||||
})
|
||||
} else {
|
||||
_ = a.Stages.CompleteByName(r.Context(), runID, "SpecValidate", model.StagePassed, string(summaryBuf))
|
||||
_ = a.Runner.CompleteStage(r.Context(), runID, "SpecValidate", model.StagePassed, string(summaryBuf))
|
||||
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageCompleted); err != nil {
|
||||
log.Printf("specvalidate: advance: %v", err)
|
||||
}
|
||||
@@ -591,7 +591,7 @@ func (a *Agent) readInventoryArtifact(r *http.Request, runID int64) (*spec.Inven
|
||||
}
|
||||
|
||||
func (a *Agent) failStage(r *http.Request, runID int64, stage, message string) {
|
||||
_ = a.Stages.CompleteByName(r.Context(), runID, stage, model.StageFailed, fmt.Sprintf(`{"error":%q}`, message))
|
||||
_ = a.Runner.CompleteStage(r.Context(), runID, stage, model.StageFailed, fmt.Sprintf(`{"error":%q}`, message))
|
||||
_ = a.Runs.SetFailedStage(r.Context(), runID, stage)
|
||||
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
|
||||
log.Printf("failStage: transition run %d: %v", runID, err)
|
||||
@@ -889,7 +889,7 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) {
|
||||
"stages": len(stages),
|
||||
"diffs": len(diffs),
|
||||
})
|
||||
if err := a.Stages.CompleteByName(ctx, runID, "Reporting", model.StagePassed, string(summaryBuf)); err != nil {
|
||||
if err := a.Runner.CompleteStage(ctx, runID, "Reporting", model.StagePassed, string(summaryBuf)); err != nil {
|
||||
log.Printf("reporting: complete stage: %v", err)
|
||||
}
|
||||
if err := a.Runs.MarkCompleted(ctx, runID, path); err != nil {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"vetting/internal/api"
|
||||
"vetting/internal/db"
|
||||
"vetting/internal/events"
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/orchestrator"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
hosts := &store.Hosts{DB: conn}
|
||||
runs := &store.Runs{DB: conn}
|
||||
stages := &store.Stages{DB: conn}
|
||||
diffs := &store.SpecDiffs{DB: conn}
|
||||
arts := &store.Artifacts{DB: conn}
|
||||
hub := events.NewHub()
|
||||
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
||||
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
|
||||
ui := &api.UI{
|
||||
Hosts: hosts,
|
||||
Runs: runs,
|
||||
Stages: stages,
|
||||
SpecDiffs: diffs,
|
||||
Artifacts: arts,
|
||||
EventHub: hub,
|
||||
Runner: runner,
|
||||
Tiles: tiles,
|
||||
}
|
||||
return ui, hosts, runs
|
||||
}
|
||||
|
||||
func detailReq(id int64) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d", id), nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", fmt.Sprintf("%d", id))
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestHostDetail_OK(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "detail-host",
|
||||
MAC: "aa:bb:cc:dd:ee:30",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(ctx, id, "deadbeef")
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "detail-host") {
|
||||
t.Fatalf("body missing host name: %s", body)
|
||||
}
|
||||
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID)
|
||||
if !strings.Contains(body, wantPipelineID) {
|
||||
t.Fatalf("body missing %s", wantPipelineID)
|
||||
}
|
||||
wantLogID := fmt.Sprintf(`id="log-%d"`, runID)
|
||||
if !strings.Contains(body, wantLogID) {
|
||||
t.Fatalf("body missing %s", wantLogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetail_NeverRun(t *testing.T) {
|
||||
ui, hosts, _ := setupDetail(t)
|
||||
id, err := hosts.Create(context.Background(), model.Host{
|
||||
Name: "never-run",
|
||||
MAC: "aa:bb:cc:dd:ee:31",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "Start vetting") {
|
||||
t.Fatalf("never-run page missing Start vetting: %s", body)
|
||||
}
|
||||
// Ghost pipeline: all nodes rendered but none are running/failed/passed.
|
||||
if strings.Contains(body, "stage-dot-running") || strings.Contains(body, "stage-dot-failed") {
|
||||
t.Fatalf("ghost pipeline should have no running/failed dots: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "stage-dot-pending") {
|
||||
t.Fatalf("expected pending stage dots in ghost pipeline: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetail_UnknownID(t *testing.T) {
|
||||
ui, _, _ := setupDetail(t)
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(9999))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetail_BadID(t *testing.T) {
|
||||
ui, _, _ := setupDetail(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/hosts/not-a-number", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", "not-a-number")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
type UI struct {
|
||||
Hosts *store.Hosts
|
||||
Runs *store.Runs
|
||||
Stages *store.Stages
|
||||
SpecDiffs *store.SpecDiffs
|
||||
Artifacts *store.Artifacts
|
||||
EventHub *events.Hub
|
||||
Runner *orchestrator.Runner
|
||||
@@ -74,6 +76,51 @@ 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) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
host, err := u.Hosts.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
latest, err := u.Runs.LatestForHost(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var stages []model.Stage
|
||||
var diffs []model.SpecDiff
|
||||
if latest != nil {
|
||||
if u.Stages != nil {
|
||||
stages, _ = u.Stages.ListForRun(r.Context(), latest.ID)
|
||||
}
|
||||
if u.SpecDiffs != nil {
|
||||
diffs, _ = u.SpecDiffs.ListForRun(r.Context(), latest.ID)
|
||||
}
|
||||
}
|
||||
t := u.Tiles.Build(r.Context(), *host, latest)
|
||||
data := templates.HostDetailData{
|
||||
Tile: t,
|
||||
Stages: stages,
|
||||
SpecDiffs: diffs,
|
||||
}
|
||||
_ = templates.HostDetail(data).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
// StartRun creates a new Run for the host, issues an agent token, and
|
||||
// transitions Registered→Queued. The dispatcher goroutine picks it up
|
||||
// and fires WoL.
|
||||
|
||||
Reference in New Issue
Block a user