ui: split /hosts/{id} into host page + /runs/{runID} run page
CI / Lint + build + test (push) Successful in 1m35s
Release / release (push) Successful in 23m47s

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:
2026-04-18 20:37:57 -04:00
parent 5c6bfa5ffa
commit 19608bef1b
23 changed files with 3173 additions and 2827 deletions
+16 -76
View File
@@ -2,13 +2,11 @@ package templates
import (
"context"
"fmt"
"strings"
"testing"
"time"
"vetting/internal/model"
"vetting/internal/store"
)
func TestHumanAgoFrom(t *testing.T) {
@@ -98,30 +96,20 @@ func TestHostTile_DisabledStartWhenOffline(t *testing.T) {
}
}
// TestHostTile_MiniRunView asserts the tile renders a step-list entry
// for every canonical stage, colours the dots according to the mixed
// stage states in the fixture, and surfaces the run id + duration in
// the meta row. This is the contract the dashboard leans on: the
// operator should be able to read run health across all tiles without
// drilling into any of them.
func TestHostTile_MiniRunView(t *testing.T) {
// TestHostTile_NoStageStrip: the tile no longer carries the Phase 3
// per-stage mini run-view — the runs-table on /hosts/{id} owns the
// stage-strip now. Guards against the regression that would bring
// `tile-steplist` / `tile-step-name` / `tile-run-duration` back.
func TestHostTile_NoStageStrip(t *testing.T) {
now := time.Now()
started := now.Add(-3 * time.Minute)
latest := &model.Run{
ID: 17,
State: model.StateSMART,
StartedAt: started,
}
// Mixed states: first two stages passed, SMART running, rest pending.
stages := []model.Stage{
{Name: "Inventory", State: model.StagePassed},
{Name: "SpecValidate", State: model.StagePassed},
{Name: "SMART", State: model.StageRunning},
StartedAt: now.Add(-3 * time.Minute),
}
data := TileData{
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
Latest: latest,
Stages: stages,
LastSeenAt: &now,
}
var buf strings.Builder
@@ -129,66 +117,18 @@ func TestHostTile_MiniRunView(t *testing.T) {
t.Fatalf("render: %v", err)
}
html := buf.String()
// Step list exists and contains every canonical stage name so the
// operator reads a full 9-dot strip regardless of how far the run got.
if !strings.Contains(html, `<ol class="tile-steplist">`) {
t.Fatalf("tile missing step list: %s", html)
}
for _, s := range store.DefaultStageOrder {
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, s)
if !strings.Contains(html, want) {
t.Fatalf("tile missing step name %q: %s", s, html)
for _, dropped := range []string{
`tile-steplist`,
`tile-step-name`,
`tile-step-dot`,
`tile-run-id`,
`tile-run-duration`,
`tile-meta-row`,
} {
if strings.Contains(html, dropped) {
t.Errorf("host tile leaked dropped class %q: %s", dropped, html)
}
}
// Colours: the two passed stages got passed dots; SMART got a running
// dot; CPUStress (no fixture row) falls back to pending.
mustContain := []string{
`stage-dot stage-dot-sm stage-dot-passed`,
`stage-dot stage-dot-sm stage-dot-running`,
`stage-dot stage-dot-sm stage-dot-pending`,
}
for _, c := range mustContain {
if !strings.Contains(html, c) {
t.Fatalf("tile missing expected dot classes %q: %s", c, html)
}
}
// Meta row: run id + a duration string (minutes for a 3m-old run).
if !strings.Contains(html, `#17`) {
t.Fatalf("tile missing run id #17: %s", html)
}
if !strings.Contains(html, `class="tile-run-duration"`) {
t.Fatalf("tile missing duration element: %s", html)
}
}
// TestHostTile_GhostSteplist: a never-run host still gets a 9-dot
// ghost strip (all pending). Keeps the tile height stable so the
// dashboard grid doesn't reflow as hosts gain their first run.
func TestHostTile_GhostSteplist(t *testing.T) {
now := time.Now()
data := TileData{
Host: model.Host{ID: 1, Name: "fresh", MAC: "aa:bb:cc:dd:ee:01"},
LastSeenAt: &now,
}
var buf strings.Builder
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
t.Fatalf("render: %v", err)
}
html := buf.String()
for _, s := range store.DefaultStageOrder {
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, s)
if !strings.Contains(html, want) {
t.Fatalf("ghost tile missing stage %q: %s", s, html)
}
}
if strings.Contains(html, `stage-dot-passed`) || strings.Contains(html, `stage-dot-running`) || strings.Contains(html, `stage-dot-failed`) {
t.Fatalf("ghost tile should have only pending dots: %s", html)
}
// No run → no meta row (suppresses "#0 · 0s" when no run exists).
if strings.Contains(html, `class="tile-run-id"`) {
t.Fatalf("ghost tile should omit run id: %s", html)
}
}
func TestLastSeenLabelAndClass(t *testing.T) {