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
+31 -19
View File
@@ -78,7 +78,6 @@ func main() {
tiles := &api.TileEnricher{ tiles := &api.TileEnricher{
Runs: runStore, Runs: runStore,
Stages: stageStore,
Artifacts: artifactStore, Artifacts: artifactStore,
SpecDiffs: specDiffStore, SpecDiffs: specDiffStore,
} }
@@ -113,28 +112,41 @@ func main() {
PublicURL: cfg.Server.PublicURL, PublicURL: cfg.Server.PublicURL,
} }
// Inject the host-detail fragment renderer. The closure reuses // Inject the host-page + run-page fragment renderers. Each reuses
// LoadHostDetailData so the SSE-pushed HTML matches an identical // the matching LoadHostPageData / LoadRunPageData so SSE-pushed HTML
// reload-rendered page byte-for-byte, then hands each region to // matches an initial page load byte-for-byte, then hands each region
// its Render*String helper. // to its Render*String helper.
orchestrator.HostDetailRenderer = func(ctx context.Context, hostID int64) (orchestrator.HostDetailFragments, bool) { orchestrator.HostPageRenderer = func(ctx context.Context, hostID int64) (orchestrator.HostPageFragments, bool) {
// Orchestrator-side publishes always reference the latest run — d, err := ui.LoadHostPageData(ctx, hostID)
// SSE topics are keyed by runID, so a stale ?run=N bookmark
// doesn't affect what the server pushes.
d, err := ui.LoadHostDetailData(ctx, hostID, 0)
if err != nil { if err != nil {
return orchestrator.HostDetailFragments{}, false return orchestrator.HostPageFragments{}, false
} }
f := orchestrator.HostDetailFragments{ rows := make(map[int64]string, len(d.Runs))
Summary: templates.RenderDetailSummaryString(d), for _, r := range d.Runs {
Actions: templates.RenderDetailActionsString(d), rows[r.ID] = templates.RenderRunRowString(templates.RunRowData{
SpecDiffs: templates.RenderDetailSpecDiffsString(d), Run: r,
Hold: templates.RenderDetailHoldString(d), Stages: d.RunStages[r.ID],
Live: d.ActiveRun != nil && d.ActiveRun.ID == r.ID,
})
} }
if d.Tile.Latest != nil { return orchestrator.HostPageFragments{
f.LatestRunID = d.Tile.Latest.ID Summary: templates.RenderHostSummaryString(d),
Actions: templates.RenderHostActionsString(d),
InFlightBanner: templates.RenderInFlightBannerString(d),
RunRows: rows,
}, true
}
orchestrator.RunPageRenderer = func(ctx context.Context, runID int64) (orchestrator.RunPageFragments, bool) {
d, err := ui.LoadRunPageData(ctx, runID)
if err != nil {
return orchestrator.RunPageFragments{}, false
} }
return f, true return orchestrator.RunPageFragments{
Header: templates.RenderRunHeaderString(d),
Hold: templates.RenderHoldBannerString(d),
SpecDiffs: templates.RenderRunSpecDiffsString(d),
}, true
} }
agentAPI := &api.Agent{ agentAPI := &api.Agent{
-455
View File
@@ -1,455 +0,0 @@
package api_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"vetting/internal/api"
"vetting/internal/db"
"vetting/internal/events"
"vetting/internal/logs"
"vetting/internal/model"
"vetting/internal/orchestrator"
"vetting/internal/store"
)
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
t.Helper()
tmp := t.TempDir()
conn, err := db.Open(filepath.Join(tmp, "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}
subSteps := &store.SubSteps{DB: conn}
diffs := &store.SpecDiffs{DB: conn}
arts := &store.Artifacts{DB: conn}
hub := events.NewHub()
logsHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub)
if err != nil {
t.Fatalf("logs hub: %v", err)
}
t.Cleanup(logsHub.Close)
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,
SubSteps: subSteps,
SpecDiffs: diffs,
Artifacts: arts,
EventHub: hub,
Logs: logsHub,
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))
}
// detailReqWithQuery is detailReq with an optional ?run= query string.
// Used by TestHostDetail_RunQueryParam so we can drive the selected-run
// branch without routing through the real router.
func detailReqWithQuery(id int64, rawQuery string) *http.Request {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d?%s", id, rawQuery), 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", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
if err := ui.Stages.Seed(ctx, runID); err != nil {
t.Fatalf("seed stages: %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)
}
// Each stage owns its own log pane; assert one of them is present.
wantLogID := fmt.Sprintf(`id="log-%d-Inventory"`, 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)
}
}
// TestHostDetail_ActiveStepsRendered: every canonical stage gets its own
// <details data-stage="..."> panel with a matching log pane id, replacing
// the old flat log-tab scaffold. Also confirms the sub-step SSE swap
// target exists when sub-steps are seeded for a stage (so Phase 1's
// substep-* event path has a DOM home).
func TestHostDetail_ActiveStepsRendered(t *testing.T) {
ui, hosts, runs := setupDetail(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "steps-host",
MAC: "aa:bb:cc:dd:ee:40",
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, "cafef00d", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
if err := ui.Stages.Seed(ctx, runID); err != nil {
t.Fatalf("seed stages: %v", err)
}
// Seed one CPUStress sub-step so the SubStepRow swap target lands.
if err := ui.SubSteps.Upsert(ctx, model.SubStep{
RunID: runID,
StageName: "CPUStress",
Ordinal: 0,
Name: "CPU pass",
State: model.StagePending,
}); err != nil {
t.Fatalf("upsert sub-step: %v", err)
}
rr := httptest.NewRecorder()
ui.HostDetail(rr, detailReq(id))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
body := rr.Body.String()
// Every stage in DefaultStageOrder owns a collapsible panel + log pane.
for _, s := range store.DefaultStageOrder {
wantPanel := fmt.Sprintf(`data-stage="%s"`, s)
if !strings.Contains(body, wantPanel) {
t.Fatalf("body missing active-step panel %s", wantPanel)
}
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
if !strings.Contains(body, wantPane) {
t.Fatalf("body missing stage log pane %s", wantPane)
}
}
// Sub-step row for CPUStress/0 is present and SSE-bound.
wantSub := fmt.Sprintf(`id="substep-%d-CPUStress-0"`, runID)
if !strings.Contains(body, wantSub) {
t.Fatalf("body missing sub-step row %s", wantSub)
}
wantSubSwap := fmt.Sprintf(`sse-swap="substep-%d-CPUStress-0"`, runID)
if !strings.Contains(body, wantSubSwap) {
t.Fatalf("body missing sub-step sse-swap %s", wantSubSwap)
}
}
// defaultOpenStage returns the value of data-stage on the single
// `<details ... open data-stage="...">` emitted by ActiveStep. Returns
// "" if no stage is currently open. The rendered attribute order is
// fixed by active_step.templ (class, then open?, then data-stage), so
// a tight substring match is safe.
func defaultOpenStage(body string) string {
re := regexp.MustCompile(`open data-stage="([^"]+)"`)
m := re.FindStringSubmatch(body)
if len(m) < 2 {
return ""
}
return m[1]
}
func TestHostDetail_DefaultStep_Running(t *testing.T) {
ui, hosts, runs := setupDetail(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "default-running",
MAC: "aa:bb:cc:dd:ee:50",
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, "t-running", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
if err := ui.Stages.Seed(ctx, runID); err != nil {
t.Fatalf("seed stages: %v", err)
}
// Earlier stages passed; SMART is running.
for _, name := range []string{"Inventory", "SpecValidate"} {
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
t.Fatalf("start %s: %v", name, err)
}
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
t.Fatalf("complete %s: %v", name, err)
}
}
if err := ui.Stages.StartByName(ctx, runID, "SMART"); err != nil {
t.Fatalf("start SMART: %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())
}
if got := defaultOpenStage(rr.Body.String()); got != "SMART" {
t.Fatalf("default step = %q, want SMART", got)
}
}
func TestHostDetail_DefaultStep_Failed(t *testing.T) {
ui, hosts, runs := setupDetail(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "default-failed",
MAC: "aa:bb:cc:dd:ee:51",
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, "t-failed", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
if err := ui.Stages.Seed(ctx, runID); err != nil {
t.Fatalf("seed stages: %v", err)
}
// Inventory + SpecValidate + SMART passed; CPUStress failed; nothing
// running. Default must land on CPUStress.
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
t.Fatalf("start %s: %v", name, err)
}
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
t.Fatalf("complete %s: %v", name, err)
}
}
if err := ui.Stages.StartByName(ctx, runID, "CPUStress"); err != nil {
t.Fatalf("start CPUStress: %v", err)
}
if err := ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`); err != nil {
t.Fatalf("complete CPUStress: %v", err)
}
rr := httptest.NewRecorder()
ui.HostDetail(rr, detailReq(id))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" {
t.Fatalf("default step = %q, want CPUStress", got)
}
}
func TestHostDetail_DefaultStep_Completed(t *testing.T) {
ui, hosts, runs := setupDetail(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "default-done",
MAC: "aa:bb:cc:dd:ee:52",
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, "t-done", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
if err := ui.Stages.Seed(ctx, runID); err != nil {
t.Fatalf("seed stages: %v", err)
}
// All stages passed → default lands on Reporting.
for _, name := range store.DefaultStageOrder {
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
t.Fatalf("start %s: %v", name, err)
}
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
t.Fatalf("complete %s: %v", name, err)
}
}
rr := httptest.NewRecorder()
ui.HostDetail(rr, detailReq(id))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
t.Fatalf("default step = %q, want Reporting", got)
}
}
// TestHostDetail_RunQueryParam: ?run=N selects a specific past run
// instead of the latest. The history sidebar's links rely on this.
func TestHostDetail_RunQueryParam(t *testing.T) {
ui, hosts, runs := setupDetail(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "query-run",
MAC: "aa:bb:cc:dd:ee:53",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
if err != nil {
t.Fatalf("create host: %v", err)
}
// Older run: failed at CPUStress. Newer run: fully passed.
oldRun, err := runs.Create(ctx, id, "old", false)
if err != nil {
t.Fatalf("create old run: %v", err)
}
if err := ui.Stages.Seed(ctx, oldRun); err != nil {
t.Fatalf("seed old: %v", err)
}
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
_ = ui.Stages.StartByName(ctx, oldRun, name)
_ = ui.Stages.CompleteByName(ctx, oldRun, name, model.StagePassed, "")
}
_ = ui.Stages.StartByName(ctx, oldRun, "CPUStress")
_ = ui.Stages.CompleteByName(ctx, oldRun, "CPUStress", model.StageFailed, "")
// Newer run lands after a tiny gap so Runs.LatestForHost picks it.
time.Sleep(10 * time.Millisecond)
newRun, err := runs.Create(ctx, id, "new", false)
if err != nil {
t.Fatalf("create new run: %v", err)
}
if err := ui.Stages.Seed(ctx, newRun); err != nil {
t.Fatalf("seed new: %v", err)
}
for _, name := range store.DefaultStageOrder {
_ = ui.Stages.StartByName(ctx, newRun, name)
_ = ui.Stages.CompleteByName(ctx, newRun, name, model.StagePassed, "")
}
// Sanity: with no ?run=, default is Reporting (latest run is green).
rr := httptest.NewRecorder()
ui.HostDetail(rr, detailReq(id))
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
t.Fatalf("latest default = %q, want Reporting", got)
}
// With ?run={old}, we view the failed run → default is CPUStress and
// the pipeline section references the old run's ID.
rr = httptest.NewRecorder()
ui.HostDetail(rr, detailReqWithQuery(id, fmt.Sprintf("run=%d", oldRun)))
body := rr.Body.String()
if got := defaultOpenStage(body); got != "CPUStress" {
t.Fatalf("?run=old default = %q, want CPUStress", got)
}
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, oldRun)
if !strings.Contains(body, wantPipelineID) {
t.Fatalf("?run=old body missing %s", wantPipelineID)
}
// A ?run= value that belongs to no host at all falls back to latest
// silently (stale bookmark should never 4xx).
rr = httptest.NewRecorder()
ui.HostDetail(rr, detailReqWithQuery(id, "run=9999"))
if rr.Code != http.StatusOK {
t.Fatalf("bogus run fallback status = %d", rr.Code)
}
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
t.Fatalf("bogus run default = %q, want Reporting (fall back to latest)", got)
}
}
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)
}
}
+216
View File
@@ -0,0 +1,216 @@
package api_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"vetting/internal/api"
"vetting/internal/db"
"vetting/internal/events"
"vetting/internal/logs"
"vetting/internal/model"
"vetting/internal/orchestrator"
"vetting/internal/store"
)
func setupPage(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
t.Helper()
tmp := t.TempDir()
conn, err := db.Open(filepath.Join(tmp, "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}
subSteps := &store.SubSteps{DB: conn}
diffs := &store.SpecDiffs{DB: conn}
arts := &store.Artifacts{DB: conn}
hub := events.NewHub()
logsHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub)
if err != nil {
t.Fatalf("logs hub: %v", err)
}
t.Cleanup(logsHub.Close)
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,
SubSteps: subSteps,
SpecDiffs: diffs,
Artifacts: arts,
EventHub: hub,
Logs: logsHub,
Runner: runner,
Tiles: tiles,
}
return ui, hosts, runs
}
func hostReq(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))
}
// TestHostPage_RunsTableRendered: GET /hosts/{id} renders one <tr> per
// run with the compact stage-strip in each row. This is the operator's
// "what has happened here" view.
func TestHostPage_RunsTableRendered(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "runs-table",
MAC: "aa:bb:cc:dd:ee:60",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
if err != nil {
t.Fatalf("create host: %v", err)
}
// Two runs: older completed, newer still in flight.
oldID, err := runs.Create(ctx, id, "old", false)
if err != nil {
t.Fatalf("create old: %v", err)
}
if err := ui.Stages.Seed(ctx, oldID); err != nil {
t.Fatalf("seed old: %v", err)
}
time.Sleep(10 * time.Millisecond)
newID, err := runs.Create(ctx, id, "new", false)
if err != nil {
t.Fatalf("create new: %v", err)
}
if err := ui.Stages.Seed(ctx, newID); err != nil {
t.Fatalf("seed new: %v", err)
}
rr := httptest.NewRecorder()
ui.HostPage(rr, hostReq(id))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, runID := range []int64{oldID, newID} {
wantRow := fmt.Sprintf(`id="runrow-%d"`, runID)
if !strings.Contains(body, wantRow) {
t.Fatalf("body missing %s:\n%s", wantRow, body)
}
wantLink := fmt.Sprintf(`href="/runs/%d"`, runID)
if !strings.Contains(body, wantLink) {
t.Fatalf("body missing run link %s", wantLink)
}
}
if !strings.Contains(body, `class="stage-strip"`) {
t.Fatalf("body missing stage-strip: %s", body)
}
if !strings.Contains(body, `class="runs-table"`) {
t.Fatalf("body missing runs-table: %s", body)
}
}
// TestHostPage_EmptyState: a never-run host renders the empty-state
// banner + a big Start vetting button instead of the runs table.
func TestHostPage_EmptyState(t *testing.T) {
ui, hosts, _ := setupPage(t)
id, err := hosts.Create(context.Background(), model.Host{
Name: "empty-host",
MAC: "aa:bb:cc:dd:ee:61",
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.HostPage(rr, hostReq(id))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `class="host-empty-state"`) {
t.Fatalf("body missing empty-state: %s", body)
}
if strings.Contains(body, `class="runs-table"`) {
t.Fatalf("empty-state should not emit runs-table: %s", body)
}
if !strings.Contains(body, "Start vetting") {
t.Fatalf("empty-state should offer Start vetting: %s", body)
}
}
// TestHostPage_InFlightBanner: a live (non-terminal) run renders the
// sticky in-flight banner pointing at /runs/{N}; the matching runs-table
// row gets the .runs-row-live highlight class.
func TestHostPage_InFlightBanner(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "live-host",
MAC: "aa:bb:cc:dd:ee:62",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
})
if err != nil {
t.Fatalf("create host: %v", err)
}
runID, err := runs.Create(ctx, id, "live", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
// Queued is non-terminal — enough to trigger the banner.
rr := httptest.NewRecorder()
ui.HostPage(rr, hostReq(id))
body := rr.Body.String()
wantOpen := fmt.Sprintf(`href="/runs/%d"`, runID)
if !strings.Contains(body, wantOpen) {
t.Fatalf("body missing in-flight link %s", wantOpen)
}
if !strings.Contains(body, "in progress") {
t.Fatalf("body missing in-flight banner text: %s", body)
}
if !strings.Contains(body, `runs-row-live`) {
t.Fatalf("body missing runs-row-live highlight: %s", body)
}
}
// TestHostPage_UnknownID: a host that doesn't exist returns 404 — a
// stale bookmark or typo shouldn't render anything.
func TestHostPage_UnknownID(t *testing.T) {
ui, _, _ := setupPage(t)
rr := httptest.NewRecorder()
ui.HostPage(rr, hostReq(9999))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
// TestHostPage_BadID: a non-numeric path segment returns 400, not a
// mysterious 500.
func TestHostPage_BadID(t *testing.T) {
ui, _, _ := setupPage(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.HostPage(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
}
+243
View File
@@ -0,0 +1,243 @@
package api_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"vetting/internal/model"
"vetting/internal/store"
)
func runReq(runID int64) *http.Request {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/runs/%d", runID), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("runID", fmt.Sprintf("%d", runID))
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
// defaultOpenStage returns the data-stage attr of the single
// `<details ... open data-stage="...">` rendered by ActiveStep.
// Rendered attribute order is fixed (class, then open?, then
// data-stage), so a tight substring match is safe.
func defaultOpenStage(body string) string {
re := regexp.MustCompile(`open data-stage="([^"]+)"`)
m := re.FindStringSubmatch(body)
if len(m) < 2 {
return ""
}
return m[1]
}
// TestRunPage_Pipeline: GET /runs/{id} emits the breadcrumb, run
// header, pipeline section, and active-step panels for every canonical
// stage. Sanity check for the run-page shell.
func TestRunPage_Pipeline(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "run-pipeline",
MAC: "aa:bb:cc:dd:ee:70",
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, "rp", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
if err := ui.Stages.Seed(ctx, runID); err != nil {
t.Fatalf("seed stages: %v", err)
}
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
}
body := rr.Body.String()
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID)
if !strings.Contains(body, wantPipelineID) {
t.Fatalf("body missing %s", wantPipelineID)
}
wantHeader := fmt.Sprintf(`id="run-header-%d"`, runID)
if !strings.Contains(body, wantHeader) {
t.Fatalf("body missing %s", wantHeader)
}
// Breadcrumb: Dashboard / host / run #N — three segments.
wantCrumb := fmt.Sprintf(`run #%d`, runID)
if !strings.Contains(body, wantCrumb) {
t.Fatalf("body missing breadcrumb %q", wantCrumb)
}
if !strings.Contains(body, `href="/hosts/`) {
t.Fatalf("body missing breadcrumb host link: %s", body)
}
// Every stage gets its own active-step panel with a log pane.
for _, s := range store.DefaultStageOrder {
wantPanel := fmt.Sprintf(`data-stage="%s"`, s)
if !strings.Contains(body, wantPanel) {
t.Fatalf("body missing active-step panel %s", wantPanel)
}
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
if !strings.Contains(body, wantPane) {
t.Fatalf("body missing log pane %s", wantPane)
}
}
}
// TestRunPage_DefaultStep_Running: while a stage is running, its
// <details open> opens by default so the operator's eye lands on the
// live work first.
func TestRunPage_DefaultStep_Running(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, _ := hosts.Create(ctx, model.Host{
Name: "run-running",
MAC: "aa:bb:cc:dd:ee:71",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
runID, _ := runs.Create(ctx, id, "rr", false)
_ = ui.Stages.Seed(ctx, runID)
for _, name := range []string{"Inventory", "SpecValidate"} {
_ = ui.Stages.StartByName(ctx, runID, name)
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
}
_ = ui.Stages.StartByName(ctx, runID, "SMART")
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
if got := defaultOpenStage(rr.Body.String()); got != "SMART" {
t.Fatalf("default step = %q, want SMART", got)
}
}
// TestRunPage_DefaultStep_Failed: no stage running but one failed →
// default opens the failed stage so the operator reads the blocker.
func TestRunPage_DefaultStep_Failed(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, _ := hosts.Create(ctx, model.Host{
Name: "run-failed",
MAC: "aa:bb:cc:dd:ee:72",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
runID, _ := runs.Create(ctx, id, "rf", false)
_ = ui.Stages.Seed(ctx, runID)
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
_ = ui.Stages.StartByName(ctx, runID, name)
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
}
_ = ui.Stages.StartByName(ctx, runID, "CPUStress")
_ = ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`)
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" {
t.Fatalf("default step = %q, want CPUStress", got)
}
}
// TestRunPage_DefaultStep_Completed: all stages passed → default lands
// on Reporting (where the report link lives).
func TestRunPage_DefaultStep_Completed(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, _ := hosts.Create(ctx, model.Host{
Name: "run-done",
MAC: "aa:bb:cc:dd:ee:73",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
runID, _ := runs.Create(ctx, id, "rd", false)
_ = ui.Stages.Seed(ctx, runID)
for _, name := range store.DefaultStageOrder {
_ = ui.Stages.StartByName(ctx, runID, name)
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
}
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
t.Fatalf("default step = %q, want Reporting", got)
}
}
// TestRunPage_CancelForm: non-terminal run shows a Cancel button that
// posts to /hosts/{hostID}/cancel. Terminal runs omit Cancel and
// instead offer Start-new-run (posts to /hosts/{hostID}/start).
func TestRunPage_CancelForm(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, _ := hosts.Create(ctx, model.Host{
Name: "run-cancel",
MAC: "aa:bb:cc:dd:ee:74",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
runID, _ := runs.Create(ctx, id, "rc", false)
// Non-terminal (Queued): Cancel form present.
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
body := rr.Body.String()
wantCancel := fmt.Sprintf(`/hosts/%d/cancel`, id)
if !strings.Contains(body, wantCancel) {
t.Fatalf("non-terminal run missing Cancel form %s", wantCancel)
}
if strings.Contains(body, "Start new run") {
t.Fatalf("non-terminal run should not offer Start new run: %s", body)
}
// Now flip to Cancelled (terminal) and re-render.
if err := ui.Runs.SetState(ctx, runID, model.StateCancelled); err != nil {
t.Fatalf("cancel run: %v", err)
}
rr = httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
body = rr.Body.String()
if strings.Contains(body, wantCancel) {
t.Fatalf("terminal run should not show Cancel form: %s", body)
}
wantStart := fmt.Sprintf(`/hosts/%d/start`, id)
if !strings.Contains(body, wantStart) {
t.Fatalf("terminal run missing Start-new-run form %s", wantStart)
}
}
// TestRunPage_UnknownRun: /runs/999999 returns 404 rather than rendering
// an empty page.
func TestRunPage_UnknownRun(t *testing.T) {
ui, _, _ := setupPage(t)
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(999999))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
// TestRunPage_BadID: a non-numeric runID returns 400.
func TestRunPage_BadID(t *testing.T) {
ui, _, _ := setupPage(t)
req := httptest.NewRequest(http.MethodGet, "/runs/bogus", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("runID", "bogus")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
ui.RunPage(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
}
-11
View File
@@ -19,7 +19,6 @@ import (
// place that renders a tile shows the same data. // place that renders a tile shows the same data.
type TileEnricher struct { type TileEnricher struct {
Runs *store.Runs Runs *store.Runs
Stages *store.Stages
Artifacts *store.Artifacts Artifacts *store.Artifacts
SpecDiffs *store.SpecDiffs SpecDiffs *store.SpecDiffs
} }
@@ -54,16 +53,6 @@ func (e *TileEnricher) Build(ctx context.Context, host model.Host, latest *model
log.Printf("tile: list artifacts run %d: %v", latest.ID, err) log.Printf("tile: list artifacts run %d: %v", latest.ID, err)
} }
} }
// Stage row per canonical stage drives the dashboard tile's mini
// run-view strip. Fail-soft: a DB hiccup renders the tile without
// dots rather than breaking the whole dashboard.
if e.Stages != nil {
if stages, err := e.Stages.ListForRun(ctx, latest.ID); err == nil {
t.Stages = stages
} else {
log.Printf("tile: list stages run %d: %v", latest.ID, err)
}
}
return t return t
} }
+120 -73
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log" "log"
"net/http" "net/http"
"regexp" "regexp"
@@ -107,28 +108,19 @@ func (u *UI) Dashboard(w http.ResponseWriter, r *http.Request) {
_ = templates.Dashboard(tiles).Render(r.Context(), w) _ = templates.Dashboard(tiles).Render(r.Context(), w)
} }
// HostDetail renders the per-host page: breadcrumb, summary, pipeline // HostPage renders /hosts/{id}: summary + actions + in-flight banner +
// timeline, hold card, action row, spec diffs, log pane, meta. Same // runs table. Run-level detail (pipeline, logs, sub-steps, spec diffs,
// enrichment path as Dashboard for tile data; additionally reads stage // hold banner) lives on /runs/{runID}. The split keeps host-scoped and
// rows + spec diffs for the latest run to populate the timeline and // run-scoped work on distinct URLs so permalinks don't wander onto
// diff list. // whichever run happens to be active.
func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) { func (u *UI) HostPage(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64) id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil { if err != nil {
http.Error(w, "bad host id", http.StatusBadRequest) http.Error(w, "bad host id", http.StatusBadRequest)
return return
} }
// Optional ?run=N: select a specific past run instead of the latest. data, err := u.LoadHostPageData(r.Context(), id)
// 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 err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r) 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) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
_ = templates.HostDetail(data).Render(r.Context(), w) _ = templates.HostPage(data).Render(r.Context(), w)
} }
// LoadHostDetailData assembles the HostDetailData payload for hostID — // LoadHostPageData assembles the HostPageData payload for hostID — host
// the same bundle the initial GET renders. Also used by the orchestrator's // metadata, the full newest-first runs list, the currently non-terminal
// PublishHostDetail path so the live SSE fragments render from identical // run (if any) for the in-flight banner, and a per-run stages map so
// inputs as the initial page, avoiding drift between reload-rendered and // the runs table can paint its compact stage-strips without re-querying
// pushed HTML. Returns store.ErrNotFound if the host doesn't exist; all // inside the template. Returns store.ErrNotFound when the host doesn't
// other store errors are surfaced to the caller. Sub-queries for stages, // exist; other store errors are surfaced. Stage lookups are fail-soft:
// diffs, replay, and tile enrichment are fail-soft (empty on error) — // a transient DB error on one run's stages yields an empty strip for
// mirrors the original inline behaviour so a transient DB hiccup on one // that row rather than blanking the whole page.
// relation doesn't blank the whole page. func (u *UI) LoadHostPageData(ctx context.Context, hostID int64) (templates.HostPageData, 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) host, err := u.Hosts.Get(ctx, hostID)
if err != nil { if err != nil {
return templates.HostDetailData{}, err return templates.HostPageData{}, err
} }
latest, err := u.Runs.LatestForHost(ctx, hostID) var runs []model.Run
if err != nil { if u.Runs != nil {
return templates.HostDetailData{}, err runs, _ = u.Runs.ListForHostAll(ctx, hostID)
} }
// Resolve the viewed run: selectedRunID wins when it matches this var active *model.Run
// host; otherwise fall back to latest. A run that belongs to a for i := range runs {
// different host is silently ignored — no operator action should be if !runs[i].State.IsTerminal() {
// able to render another host's run under this page. active = &runs[i]
viewed := latest break
if selectedRunID > 0 && u.Runs != nil {
if r, err := u.Runs.Get(ctx, selectedRunID); err == nil && r != nil && r.HostID == hostID {
viewed = r
} }
} }
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 stages []model.Stage
var diffs []model.SpecDiff
var subSteps []model.SubStep var subSteps []model.SubStep
if viewed != nil { var diffs []model.SpecDiff
if u.Stages != nil { if u.Stages != nil {
stages, _ = u.Stages.ListForRun(ctx, viewed.ID) stages, _ = u.Stages.ListForRun(ctx, runID)
}
if u.SpecDiffs != nil {
diffs, _ = u.SpecDiffs.ListForRun(ctx, viewed.ID)
}
if u.SubSteps != nil {
subSteps, _ = u.SubSteps.ListForRun(ctx, viewed.ID)
}
} }
// Sidebar: last 20 runs for this host, newest first. Fail-soft so a if u.SubSteps != nil {
// transient DB error doesn't blank the whole page. subSteps, _ = u.SubSteps.ListForRun(ctx, runID)
var history []model.Run }
if u.Runs != nil { if u.SpecDiffs != nil {
history, _ = u.Runs.ListForHost(ctx, hostID, 20) diffs, _ = u.SpecDiffs.ListForRun(ctx, runID)
} }
t := u.Tiles.Build(ctx, *host, viewed)
replay := ""
replayByStage := map[string]string{} replayByStage := map[string]string{}
if viewed != nil && u.Logs != nil { if u.Logs != nil {
replay = u.Logs.Replay(viewed.ID) replayByStage = u.Logs.ReplayByStage(runID)
replayByStage = u.Logs.ReplayByStage(viewed.ID)
} }
return templates.HostDetailData{ // Critical-diff count + hold-key path reuse the tile enricher so the
Tile: t, // 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, Stages: stages,
SpecDiffs: diffs,
SubSteps: subSteps, SubSteps: subSteps,
History: history, SpecDiffs: diffs,
DefaultStepStage: pickDefaultStep(stages), DefaultStepStage: pickDefaultStep(stages),
LogReplay: replay,
LogReplayByStage: replayByStage, LogReplayByStage: replayByStage,
HoldKeyPath: holdKeyPath,
SpecDiffCritical: critical,
}, nil }, nil
} }
@@ -285,7 +328,9 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("ui: created run %d for host %d (state=Queued)", runID, hostID) 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) { 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) http.Error(w, "override: "+err.Error(), http.StatusInternalServerError)
return 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 // 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 return
} }
log.Printf("ui: cancelled run %d for host %d", latest.ID, hostID) 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) { func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
+2 -1
View File
@@ -70,11 +70,12 @@ func NewRouter(d Deps) http.Handler {
r.Get("/", d.UI.Dashboard) r.Get("/", d.UI.Dashboard)
r.Get("/hosts/new", d.UI.NewHostForm) r.Get("/hosts/new", d.UI.NewHostForm)
r.Post("/hosts", d.UI.CreateHost) r.Post("/hosts", d.UI.CreateHost)
r.Get("/hosts/{id}", d.UI.HostDetail) r.Get("/hosts/{id}", d.UI.HostPage)
r.Post("/hosts/{id}/delete", d.UI.DeleteHost) r.Post("/hosts/{id}/delete", d.UI.DeleteHost)
r.Post("/hosts/{id}/start", d.UI.StartRun) r.Post("/hosts/{id}/start", d.UI.StartRun)
r.Post("/hosts/{id}/cancel", d.UI.CancelRun) r.Post("/hosts/{id}/cancel", d.UI.CancelRun)
r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage) r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage)
r.Get("/runs/{runID}", d.UI.RunPage)
r.Get("/reports/{runID}", d.UI.Report) r.Get("/reports/{runID}", d.UI.Report)
r.Get("/register/quick.sh", d.UI.QuickRegisterScript) r.Get("/register/quick.sh", d.UI.QuickRegisterScript)
r.Get("/events", d.UI.SSE) r.Get("/events", d.UI.SSE)
+91 -36
View File
@@ -72,19 +72,21 @@ func (r *Runner) PublishTileUpdate(ctx context.Context, hostID int64) {
r.publishTileUpdate(ctx, hostID) r.publishTileUpdate(ctx, hostID)
} }
// PublishHostDetail broadcasts fresh HTML fragments for every non-log, // PublishHostPage broadcasts fresh HTML fragments for every host-keyed
// non-pipeline region of the host detail page: summary header, actions // region on /hosts/{id}: summary card, primary-actions row, and the
// row, spec-diffs list, and the hold-key SSH block. Callers should // in-flight banner. It also fires a runrow-{runID} swap for every run
// invoke this alongside PublishTileUpdate from any site that mutates // whose row is affected by this state change (the active one plus any
// state visible on the detail page. // run that just completed). Callers should invoke this alongside
// PublishTileUpdate at every site that mutates state visible on the
// host page or its runs table.
// //
// Safe to call when no renderer has been registered or the host has // Safe to call when no renderer has been registered or the host has
// been deleted; the call is silently dropped. // been deleted; the call is silently dropped.
func (r *Runner) PublishHostDetail(ctx context.Context, hostID int64) { func (r *Runner) PublishHostPage(ctx context.Context, hostID int64) {
if HostDetailRenderer == nil || r.EventHub == nil { if HostPageRenderer == nil || r.EventHub == nil {
return return
} }
f, ok := HostDetailRenderer(ctx, hostID) f, ok := HostPageRenderer(ctx, hostID)
if !ok { if !ok {
return return
} }
@@ -96,18 +98,48 @@ func (r *Runner) PublishHostDetail(ctx context.Context, hostID int64) {
Name: fmt.Sprintf("detail-actions-%d", hostID), Name: fmt.Sprintf("detail-actions-%d", hostID),
Payload: f.Actions, Payload: f.Actions,
}) })
if f.LatestRunID != 0 { r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-inflight-%d", hostID),
Payload: f.InFlightBanner,
})
for runID, payload := range f.RunRows {
r.EventHub.Publish(events.Event{ r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-specdiffs-%d", f.LatestRunID), Name: fmt.Sprintf("runrow-%d", runID),
Payload: f.SpecDiffs, Payload: payload,
})
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-hold-%d", f.LatestRunID),
Payload: f.Hold,
}) })
} }
} }
// PublishRunPage broadcasts fresh HTML fragments for every run-keyed
// region on /runs/{runID}: header (with Cancel / Start-new-run /
// View-report), hold banner, and spec diffs. The pipeline is already
// fired separately from publishTileUpdate. Caller is any site that
// transitions run state or writes a spec-diff / hold row.
//
// Safe to call when no renderer has been registered or the run has
// been deleted; the call is silently dropped.
func (r *Runner) PublishRunPage(ctx context.Context, runID int64) {
if RunPageRenderer == nil || r.EventHub == nil {
return
}
f, ok := RunPageRenderer(ctx, runID)
if !ok {
return
}
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("run-header-%d", runID),
Payload: f.Header,
})
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-hold-%d", runID),
Payload: f.Hold,
})
r.EventHub.Publish(events.Event{
Name: fmt.Sprintf("detail-specdiffs-%d", runID),
Payload: f.SpecDiffs,
})
}
func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) { func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
host, err := r.Hosts.Get(ctx, hostID) host, err := r.Hosts.Get(ctx, hostID)
if err != nil { if err != nil {
@@ -135,11 +167,19 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
} }
} }
// Detail-page fragments — everything on /hosts/{id} that isn't the // Host-page fragments — everything on /hosts/{id} that isn't the
// pipeline or the log pane. Co-located here so every site that // pipeline or the log pane: summary card, primary actions, in-flight
// already publishes a tile refresh also refreshes the detail page // banner, and per-run row swaps in the runs table. Co-located here
// without the caller having to remember a second call. // so every tile-refresh site also refreshes the host page without
r.PublishHostDetail(ctx, hostID) // the caller having to remember a second call.
r.PublishHostPage(ctx, hostID)
// Run-page fragments — header (cancel button visibility), hold
// banner, spec diffs. Fires alongside the tile + pipeline refreshes
// so any state-change site covers both /hosts/{id} and /runs/{runID}.
if latest != nil {
r.PublishRunPage(ctx, latest.ID)
}
} }
// TileRenderer renders a single tile fragment. Registered at startup // TileRenderer renders a single tile fragment. Registered at startup
@@ -173,24 +213,39 @@ func (r *Runner) PublishSubStepUpdate(ctx context.Context, ss model.SubStep) {
}) })
} }
// HostDetailFragments is the pre-rendered bundle of HTML fragments a // HostPageFragments is the pre-rendered bundle a single PublishHostPage
// single PublishHostDetail call broadcasts over SSE. Summary and Actions // call broadcasts over SSE. Summary, Actions, and InFlightBanner are
// are always set; SpecDiffs and Hold are empty strings when there is no // always set. RunRows is a runID → pre-rendered <tr> map so every row
// latest run (the corresponding events are not published in that case). // whose state just changed refreshes atomically (typically only the
type HostDetailFragments struct { // active run, plus whichever run just became terminal).
Summary string type HostPageFragments struct {
Actions string Summary string
SpecDiffs string Actions string
Hold string InFlightBanner string
LatestRunID int64 // 0 when the host has no runs yet RunRows map[int64]string
} }
// HostDetailRenderer produces the four fragments for a given host. // HostPageRenderer produces the fragments for a given host. Registered
// Registered at startup by main so the orchestrator doesn't import the // at startup by main so the orchestrator doesn't import the template
// template or store-enrichment layers. Returns ok=false when the host // or store-enrichment layers. Returns ok=false when the host cannot be
// cannot be loaded (deleted, DB error); caller skips publish in that // loaded (deleted, DB error); caller skips publish in that case.
// case. var HostPageRenderer func(ctx context.Context, hostID int64) (HostPageFragments, bool)
var HostDetailRenderer func(ctx context.Context, hostID int64) (HostDetailFragments, bool)
// RunPageFragments is the pre-rendered bundle a single PublishRunPage
// call broadcasts over SSE. Header is always set; Hold and SpecDiffs
// are always set too (they emit an empty placeholder when no hold /
// diffs exist, so the first real event has a DOM target).
type RunPageFragments struct {
Header string
Hold string
SpecDiffs string
}
// RunPageRenderer produces the fragments for a given run. Registered at
// startup by main so the orchestrator doesn't import the template or
// store-enrichment layers. Returns ok=false when the run cannot be
// loaded.
var RunPageRenderer func(ctx context.Context, runID int64) (RunPageFragments, bool)
func renderTileSSE(ctx context.Context, host model.Host, latest *model.Run) string { func renderTileSSE(ctx context.Context, host model.Host, latest *model.Run) string {
if TileRenderer == nil { if TileRenderer == nil {
+32 -20
View File
@@ -33,30 +33,38 @@ func setupRunner(t *testing.T) (*orchestrator.Runner, *store.Hosts, *store.Runs,
// grep the published fragments without parsing HTML. // grep the published fragments without parsing HTML.
prevTile := orchestrator.TileRenderer prevTile := orchestrator.TileRenderer
prevPipe := orchestrator.PipelineRenderer prevPipe := orchestrator.PipelineRenderer
prevDetail := orchestrator.HostDetailRenderer prevHost := orchestrator.HostPageRenderer
prevRun := orchestrator.RunPageRenderer
orchestrator.TileRenderer = func(_ context.Context, host model.Host, _ *model.Run) string { orchestrator.TileRenderer = func(_ context.Context, host model.Host, _ *model.Run) string {
return fmt.Sprintf(`<article id="host-%d">tile</article>`, host.ID) return fmt.Sprintf(`<article id="host-%d">tile</article>`, host.ID)
} }
orchestrator.PipelineRenderer = func(run *model.Run, _ []model.Stage) string { orchestrator.PipelineRenderer = func(run *model.Run, _ []model.Stage) string {
return fmt.Sprintf(`<section id="pipeline-%d">pipeline</section>`, run.ID) return fmt.Sprintf(`<section id="pipeline-%d">pipeline</section>`, run.ID)
} }
orchestrator.HostDetailRenderer = func(_ context.Context, hostID int64) (orchestrator.HostDetailFragments, bool) { orchestrator.HostPageRenderer = func(_ context.Context, hostID int64) (orchestrator.HostPageFragments, bool) {
var runID int64 rows := map[int64]string{}
if latest, err := runs.LatestForHost(context.Background(), hostID); err == nil && latest != nil { if latest, err := runs.LatestForHost(context.Background(), hostID); err == nil && latest != nil {
runID = latest.ID rows[latest.ID] = fmt.Sprintf(`<tr id="runrow-%d">row</tr>`, latest.ID)
} }
return orchestrator.HostDetailFragments{ return orchestrator.HostPageFragments{
Summary: fmt.Sprintf(`<header id="detail-summary-%d">summary</header>`, hostID), Summary: fmt.Sprintf(`<header id="detail-summary-%d">summary</header>`, hostID),
Actions: fmt.Sprintf(`<section id="detail-actions-%d">actions</section>`, hostID), Actions: fmt.Sprintf(`<section id="detail-actions-%d">actions</section>`, hostID),
SpecDiffs: fmt.Sprintf(`<section id="detail-specdiffs-%d">diffs</section>`, runID), InFlightBanner: fmt.Sprintf(`<section id="detail-inflight-%d">inflight</section>`, hostID),
Hold: fmt.Sprintf(`<section id="detail-hold-%d">hold</section>`, runID), RunRows: rows,
LatestRunID: runID, }, true
}
orchestrator.RunPageRenderer = func(_ context.Context, runID int64) (orchestrator.RunPageFragments, bool) {
return orchestrator.RunPageFragments{
Header: fmt.Sprintf(`<header id="run-header-%d">header</header>`, runID),
Hold: fmt.Sprintf(`<section id="detail-hold-%d">hold</section>`, runID),
SpecDiffs: fmt.Sprintf(`<section id="detail-specdiffs-%d">diffs</section>`, runID),
}, true }, true
} }
cleanup := func() { cleanup := func() {
orchestrator.TileRenderer = prevTile orchestrator.TileRenderer = prevTile
orchestrator.PipelineRenderer = prevPipe orchestrator.PipelineRenderer = prevPipe
orchestrator.HostDetailRenderer = prevDetail orchestrator.HostPageRenderer = prevHost
orchestrator.RunPageRenderer = prevRun
_ = conn.Close() _ = conn.Close()
} }
return runner, hosts, runs, hub, cleanup return runner, hosts, runs, hub, cleanup
@@ -128,11 +136,12 @@ loop:
} }
} }
// TestPublishesHostDetailFragments asserts that every state-change // TestPublishesHostPageAndRunPageFragments asserts that every state-
// publish site also emits the four detail-page SSE events (summary, // change publish site emits the full set of host-page SSE events
// actions, specdiffs, hold). Without this, the host detail page // (summary, actions, in-flight banner, runrow) *and* the run-page
// stays frozen on the state at page-load time. // events (header, hold, specdiffs). Without this, neither /hosts/{id}
func TestPublishesHostDetailFragments(t *testing.T) { // nor /runs/{runID} update live.
func TestPublishesHostPageAndRunPageFragments(t *testing.T) {
runner, hosts, runs, hub, cleanup := setupRunner(t) runner, hosts, runs, hub, cleanup := setupRunner(t)
defer cleanup() defer cleanup()
ctx := context.Background() ctx := context.Background()
@@ -160,10 +169,13 @@ func TestPublishesHostDetailFragments(t *testing.T) {
} }
want := map[string]bool{ want := map[string]bool{
fmt.Sprintf("detail-summary-%d", hostID): false, fmt.Sprintf("detail-summary-%d", hostID): false,
fmt.Sprintf("detail-actions-%d", hostID): false, fmt.Sprintf("detail-actions-%d", hostID): false,
fmt.Sprintf("detail-specdiffs-%d", runID): false, fmt.Sprintf("detail-inflight-%d", hostID): false,
fmt.Sprintf("detail-hold-%d", runID): false, fmt.Sprintf("runrow-%d", runID): false,
fmt.Sprintf("run-header-%d", runID): false,
fmt.Sprintf("detail-hold-%d", runID): false,
fmt.Sprintf("detail-specdiffs-%d", runID): false,
} }
deadline := time.After(500 * time.Millisecond) deadline := time.After(500 * time.Millisecond)
for { for {
+10 -3
View File
@@ -155,9 +155,8 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
} }
// ListForHost returns the most recent `limit` runs for a host, newest // ListForHost returns the most recent `limit` runs for a host, newest
// first. Caller uses this to drive the host-detail runs sidebar (last 20 // first. Zero/negative limit falls back to a safe cap so a mistaken call
// by default, Phase 2). Zero/negative limit falls back to a safe cap so // can't scan the whole history into memory.
// a mistaken call can't scan the whole history into memory.
func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) { func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) {
if limit <= 0 { if limit <= 0 {
limit = 20 limit = 20
@@ -193,6 +192,14 @@ func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]mode
return out, rows.Err() return out, rows.Err()
} }
// ListForHostAll returns every run for a host, newest first. Caps at a
// defensive 1000 rows so a runaway host that somehow accumulated tens
// of thousands of runs doesn't blow up the page load; typical hosts
// finish with < 50.
func (r *Runs) ListForHostAll(ctx context.Context, hostID int64) ([]model.Run, error) {
return r.ListForHost(ctx, hostID, 1000)
}
// Active returns all runs in non-terminal states. // Active returns all runs in non-terminal states.
func (r *Runs) Active(ctx context.Context) ([]model.Run, error) { func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
rows, err := r.DB.QueryContext(ctx, ` rows, err := r.DB.QueryContext(ctx, `
+121 -92
View File
@@ -822,59 +822,6 @@ body.bare main { max-width: none; }
.log-line.log-debug { opacity: .6; } .log-line.log-debug { opacity: .6; }
.log-line.log-hit { background: rgba(228,169,75,.08); } .log-line.log-hit { background: rgba(228,169,75,.08); }
.runs-sidebar {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
position: sticky;
top: 16px;
max-height: calc(100vh - 32px);
overflow-y: auto;
}
.runs-sidebar-heading {
margin: 0 0 10px;
font-size: 12px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .5px;
font-weight: 600;
}
.runs-sidebar-empty { color: var(--text-dim); font-size: 13px; margin: 0; }
.runs-sidebar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.runs-sidebar-item a {
display: grid;
grid-template-columns: 16px auto 1fr auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
color: var(--text);
text-decoration: none;
font-size: 12px;
}
.runs-sidebar-item a:hover { background: var(--bg-elev-2); text-decoration: none; }
.runs-sidebar-active a { background: rgba(60,130,246,.12); border: 1px solid rgba(60,130,246,.5); }
.runs-sidebar-dot {
width: 14px;
height: 14px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
background: var(--bg-elev-2);
border: 1px solid var(--border);
color: var(--text-dim);
}
.runs-sidebar-dot-pass { background: var(--success); border-color: var(--success); color: #0b0d12; }
.runs-sidebar-dot-fail { background: var(--danger); border-color: var(--danger); color: #fff; }
.runs-sidebar-dot-active { background: var(--accent-strong); border-color: var(--accent); color: #fff; }
.runs-sidebar-id { font-family: var(--mono); font-weight: 600; }
.runs-sidebar-started { color: var(--text-dim); }
.runs-sidebar-duration { font-family: var(--mono); color: var(--text-dim); font-size: 11px; }
.btn-primary { .btn-primary {
background: var(--accent-strong); background: var(--accent-strong);
border-color: var(--accent-strong); border-color: var(--accent-strong);
@@ -888,55 +835,137 @@ body.bare main { max-width: none; }
} }
.btn-danger:hover { background: rgba(229,100,102,.1); } .btn-danger:hover { background: rgba(229,100,102,.1); }
/* ---------- Dashboard tile mini run-view (Phase 3) ---------------- */ /* ---------- Host page (/hosts/{id}) ------------------------------- */
/* Small variant of stage-dot for the compact step list. Same colour /* Small variant of stage-dot, reused by the runs-table stage-strip so
rules as the full-size pipeline dot so operators read one language per-row progress reads with the same visual language as the pipeline
everywhere; only the geometry shrinks. */ on the run page. Kept lean — no borders, no glyphs, just colour. */
.stage-dot-sm { .stage-dot-sm {
width: 14px; width: 10px;
height: 14px; height: 10px;
font-size: 9px; font-size: 0;
border-width: 1px; border-width: 1px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 50%;
} }
.tile-meta-row { .host-page { display: flex; flex-direction: column; gap: 12px; }
display: flex;
gap: 8px;
align-items: baseline;
font-size: 12px;
color: var(--text-dim);
padding: 4px 0 6px;
}
.tile-run-id { font-variant-numeric: tabular-nums; }
.tile-run-duration { margin-left: auto; font-variant-numeric: tabular-nums; }
.tile-steplist { .host-summary {
list-style: none; background: var(--bg-elev);
margin: 0 0 8px; border: 1px solid var(--border);
padding: 0; border-radius: var(--radius);
display: grid; padding: 14px 16px;
grid-template-columns: 1fr 1fr;
gap: 2px 10px;
} }
.tile-steplist .tile-step { .host-summary-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 10px;
font-size: 11px; margin-bottom: 10px;
line-height: 1.4;
color: var(--text-dim);
min-width: 0;
} }
.tile-steplist .tile-step-name { .host-summary-name { margin: 0; font-size: 22px; font-weight: 600; }
.host-summary-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 6px 20px;
margin: 0 0 6px;
font-size: 13px;
}
.host-summary-meta dt { color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: .4px; }
.host-summary-meta dd { margin: 0; font-family: var(--mono); }
.host-summary-notes { margin-top: 8px; }
.host-summary-notes h3 { margin: 0 0 4px; font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .4px; }
.host-summary-spec summary { cursor: pointer; color: var(--text-dim); font-size: 12px; }
.host-summary-spec-yaml {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-family: var(--mono);
font-size: 12px;
overflow-x: auto;
margin: 6px 0 0;
}
.host-actions { padding: 0; }
.host-actions-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.host-nd-toggle { display: inline-flex; gap: 6px; align-items: center; color: var(--text-dim); font-size: 13px; }
.in-flight-banner-wrap { display: contents; }
.in-flight-banner {
display: flex;
align-items: center;
gap: 10px;
background: rgba(60,130,246,.12);
border: 1px solid rgba(60,130,246,.5);
border-radius: var(--radius);
padding: 10px 14px;
color: var(--text);
text-decoration: none;
font-size: 14px;
}
.in-flight-banner:hover { background: rgba(60,130,246,.20); text-decoration: none; }
.in-flight-label { font-weight: 600; }
.in-flight-state { color: var(--text-dim); font-family: var(--mono); }
.in-flight-open { margin-left: auto; color: var(--accent); }
.host-empty-state {
text-align: center;
background: var(--bg-elev);
border: 1px dashed var(--border);
border-radius: var(--radius);
padding: 40px 20px;
}
.host-empty-title { font-size: 18px; font-weight: 600; margin: 0 0 4px; }
.host-empty-sub { color: var(--text-dim); margin: 0 0 16px; font-size: 13px; }
.btn-primary.big { font-size: 15px; padding: 10px 20px; }
.host-runs { }
.host-runs h2 { font-size: 14px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .4px; margin: 0 0 8px; }
.runs-table {
width: 100%;
border-collapse: collapse;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; font-size: 13px;
white-space: nowrap;
} }
/* Passed/failed/running steps keep full-strength text so the eye jumps .runs-table thead th {
to active work; pending/skipped fade back into the background. */ text-align: left;
.tile-step-passed .tile-step-name, padding: 8px 10px;
.tile-step-failed .tile-step-name, color: var(--text-dim);
.tile-step-running .tile-step-name { color: var(--text); } font-size: 11px;
.tile-step-skipped { opacity: .5; } text-transform: uppercase;
letter-spacing: .4px;
font-weight: 600;
border-bottom: 1px solid var(--border);
background: var(--bg-elev-2);
}
.runs-table tbody td {
padding: 8px 10px;
border-top: 1px solid var(--border);
vertical-align: middle;
}
.runs-table tbody tr:first-child td { border-top: none; }
.runs-table tbody tr:hover { background: var(--bg-elev-2); }
.runs-row-live { background: rgba(60,130,246,.08); }
.runs-row-live:hover { background: rgba(60,130,246,.14); }
.runs-col-id a { font-family: var(--mono); font-weight: 600; color: var(--text); text-decoration: none; }
.runs-col-id a:hover { color: var(--accent); }
.runs-col-started, .runs-col-duration { color: var(--text-dim); font-family: var(--mono); white-space: nowrap; }
.runs-open-link { color: var(--accent); text-decoration: none; font-size: 12px; white-space: nowrap; }
.runs-open-link:hover { text-decoration: underline; }
.stage-strip {
display: inline-flex;
gap: 3px;
align-items: center;
}
/* ---------- Run page (/runs/{runID}) ------------------------------ */
.run-page { display: flex; flex-direction: column; gap: 12px; }
.run-body { display: flex; flex-direction: column; gap: 10px; }
.run-header-name { margin: 0; font-size: 20px; font-weight: 600; }
+1 -5
View File
@@ -9,14 +9,10 @@ import (
// TileData pairs a host with its latest run and the derived fields the // TileData pairs a host with its latest run and the derived fields the
// tile needs to render: spec-diff count (server-side diff result) and // tile needs to render: spec-diff count (server-side diff result) and
// the on-disk path to the hold-key artifact when the run is holding. // the on-disk path to the hold-key artifact when the run is holding.
// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is // LastSeenAt is the host-mode agent's most recent heartbeat.
// the list of canonical-order stage rows for Latest, used by HostTile
// to render the mini run-view; nil/empty for never-run hosts (a ghost
// dot strip is rendered from DefaultStageOrder).
type TileData struct { type TileData struct {
Host model.Host Host model.Host
Latest *model.Run Latest *model.Run
Stages []model.Stage
SpecDiffCritical int SpecDiffCritical int
HoldKeyPath string HoldKeyPath string
LastSeenAt *time.Time LastSeenAt *time.Time
+1 -5
View File
@@ -17,14 +17,10 @@ import (
// TileData pairs a host with its latest run and the derived fields the // TileData pairs a host with its latest run and the derived fields the
// tile needs to render: spec-diff count (server-side diff result) and // tile needs to render: spec-diff count (server-side diff result) and
// the on-disk path to the hold-key artifact when the run is holding. // the on-disk path to the hold-key artifact when the run is holding.
// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is // LastSeenAt is the host-mode agent's most recent heartbeat.
// the list of canonical-order stage rows for Latest, used by HostTile
// to render the mini run-view; nil/empty for never-run hosts (a ghost
// dot strip is rendered from DefaultStageOrder).
type TileData struct { type TileData struct {
Host model.Host Host model.Host
Latest *model.Run Latest *model.Run
Stages []model.Stage
SpecDiffCritical int SpecDiffCritical int
HoldKeyPath string HoldKeyPath string
LastSeenAt *time.Time LastSeenAt *time.Time
-423
View File
@@ -1,423 +0,0 @@
package templates
import (
"bytes"
"context"
"fmt"
"time"
"vetting/internal/model"
"vetting/internal/store"
)
// HostDetailData is the full payload the detail handler hands to the
// HostDetail template. Tile carries host + viewed-run enrichment (same
// shape the dashboard tile uses), Stages/SpecDiffs/SubSteps drive the
// pipeline, diff list, and expanded step panel. History backs the runs
// sidebar (last 20, newest first). DefaultStepStage is the stage name
// whose <details> opens by default on page load — running → failed →
// Reporting. LogReplay is the pre-rendered history fragment produced
// by logs.Hub.Replay on the initial page render so the operator sees
// prior output without waiting for a fresh SSE event.
type HostDetailData struct {
Tile TileData
Stages []model.Stage
SpecDiffs []model.SpecDiff
SubSteps []model.SubStep
History []model.Run
DefaultStepStage string
LogReplay string
// LogReplayByStage is the pre-rendered log HTML grouped by stage
// name. Each ActiveStep panel picks its own bucket so the detail
// page doesn't fire nine disk scans per reload. The "" key holds
// orphan/framing lines (no stage set), surfaced under the "Run"
// pseudo-step at the top of the page.
LogReplayByStage map[string]string
}
// HostDetail is the GitHub-Actions-style run view. Layout is: meta
// drawer (collapsed) → run header + actions → hold banner → horizontal
// pipeline → two-column body (active-step pane + runs sidebar) → spec
// diffs at the bottom. Each section keeps its own sse-swap target so
// live updates don't trigger whole-page reflows.
templ HostDetail(d HostDetailData) {
@Layout(d.Tile.Host.Name) {
<section class="detail detail-v2" hx-ext="sse" sse-connect="/events">
<nav class="breadcrumb">
<a href="/">Dashboard</a>
<span class="breadcrumb-sep">/</span>
<span>{ d.Tile.Host.Name }</span>
</nav>
@HostMetaDrawer(d)
@DetailSummary(d)
@DetailActions(d)
@DetailHold(d)
if d.Tile.Latest != nil {
@PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages))
} else {
<section class="detail-section">
<h2>Pipeline</h2>
@Pipeline(BuildPipeline(nil, nil))
</section>
}
<div class="detail-body">
<div class="active-step-pane">
if d.Tile.Latest != nil {
for _, stageName := range store.DefaultStageOrder {
@ActiveStep(ActiveStepData{
RunID: d.Tile.Latest.ID,
Stage: stageForName(d.Stages, stageName),
SubSteps: SubStepsForStage(d.SubSteps, stageName),
LogReplay: d.LogReplayByStage[stageName],
Open: stageName == d.DefaultStepStage,
})
}
} else {
<p class="detail-empty">No run yet. Click <strong>Start vetting</strong> to begin.</p>
}
</div>
@RunsSidebar(d)
</div>
@DetailSpecDiffs(d)
</section>
}
}
// HostMetaDrawer is the collapsed "host details" block at the top of the
// page: MAC, WoL, last-seen, expected spec, and notes. <details> defaults
// to closed so the run itself stays above the fold; operators open it
// when they need the provisioning info.
templ HostMetaDrawer(d HostDetailData) {
<details class="host-meta-drawer">
<summary>
<span class="meta-summary-label">Host details</span>
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
<span class="meta-summary-mac">{ d.Tile.Host.MAC }</span>
</summary>
<dl class="detail-meta">
<div>
<dt>MAC</dt>
<dd>{ d.Tile.Host.MAC }</dd>
</div>
<div>
<dt>WoL</dt>
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
</div>
</dl>
if d.Tile.Host.Notes != "" {
<div class="detail-notes">
<h3>Notes</h3>
<p>{ d.Tile.Host.Notes }</p>
</div>
}
<div class="detail-spec">
<h3>Expected spec</h3>
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
</div>
</details>
}
// DetailSummary is the run header: host name on the left, run number,
// status icon, and elapsed/total duration. Keyed on host ID so the SSE
// event name is stable across run turnover.
templ DetailSummary(d HostDetailData) {
<header
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
class={ "run-header", "tile-" + tileMood(d.Tile.Latest) }
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
hx-swap="outerHTML"
>
<div class="run-header-left">
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
if d.Tile.Latest != nil {
<span class="run-number">{ fmt.Sprintf("run #%d", d.Tile.Latest.ID) }</span>
}
<span class={ "run-status-badge", "run-status-" + tileMood(d.Tile.Latest) }>{ tileStatus(d.Tile.Latest) }</span>
if d.Tile.Latest != nil {
<span class="run-duration">{ runDuration(d.Tile.Latest) }</span>
}
</div>
<div class="run-header-right">
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
<span class="run-failed-stage">failed at <strong>{ d.Tile.Latest.FailedStage }</strong></span>
}
if d.Tile.SpecDiffCritical > 0 {
<span class="run-diffs bad">{ fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical) }</span>
}
</div>
</header>
}
// DetailActions is the button row (Start / Cancel / Override / View
// report / Delete). Enabled/disabled state depends on the latest run's
// state and host heartbeat; both change live, so this section re-renders
// on every state change. Keyed on host ID — the actions exist even
// without a run.
templ DetailActions(d HostDetailData) {
<section
id={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
class="detail-section detail-actions"
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
hx-swap="outerHTML"
>
<div class="detail-actions-row">
if canStart(d.Tile) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
<label class="detail-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
</label>
<button type="submit" class="btn-primary">Start vetting</button>
</form>
} else if canStartIfOnline(d.Tile.Latest) {
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
} else {
<button type="button" disabled>Run in flight</button>
}
if canCancel(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
<button type="submit" class="btn-danger">Cancel run</button>
</form>
}
if canOverrideWipe(d.Tile.Latest) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
<button type="submit" class="btn-danger">Override wipe-probe</button>
</form>
}
if hasReport(d.Tile.Latest) {
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
}
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Delete host and all its runs?');">
<button type="submit" class="btn-danger">Delete host</button>
</form>
</div>
</section>
}
// DetailSpecDiffs renders the "Spec diffs (N)" collapsible when a run
// exists; otherwise it emits a bare empty wrapper so a later SSE push
// after SpecValidate writes has a target to swap into. The wrapper is
// keyed on run ID because the diffs belong to a specific run — a new
// run publishes to a new event name, and the detail page navigates to
// the new target via outerHTML swap only when the whole DetailSpecDiffs
// section is re-rendered by a page reload.
templ DetailSpecDiffs(d HostDetailData) {
if d.Tile.Latest != nil {
<section
id={ fmt.Sprintf("detail-specdiffs-%d", d.Tile.Latest.ID) }
class="detail-section detail-diffs"
sse-swap={ fmt.Sprintf("detail-specdiffs-%d", d.Tile.Latest.ID) }
hx-swap="outerHTML"
>
if len(d.SpecDiffs) > 0 {
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
<ul class="diff-list">
for _, diff := range d.SpecDiffs {
<li class={ "diff-row", "diff-" + diff.Severity }>
<div class="diff-field">{ diff.Field }</div>
<div class="diff-expected">expected: <code>{ diff.Expected }</code></div>
<div class="diff-actual">actual: <code>{ diff.Actual }</code></div>
</li>
}
</ul>
</details>
}
</section>
}
}
// DetailHold renders the "Host is holding — SSH available" strip across
// the top when a run is in FailedHolding with an IP recorded. Otherwise
// it emits an empty wrapper so the first SSE push when the hold actually
// fires has a target. Keyed on run ID for the same reason as
// DetailSpecDiffs.
templ DetailHold(d HostDetailData) {
if d.Tile.Latest != nil {
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
class="hold-banner"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
hx-swap="outerHTML"
>
<span class="hold-banner-label">Host is holding — SSH available:</span>
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
</section>
} else {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
class="detail-hold-placeholder"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
hx-swap="outerHTML"
></section>
}
}
}
// RunsSidebar is the right-rail history list: last 20 runs for this
// host, newest first. Each entry links back to /hosts/{id}?run=N for
// navigation into a past run. The row for the currently-viewed run is
// flagged so CSS can highlight it.
templ RunsSidebar(d HostDetailData) {
<aside class="runs-sidebar">
<h2 class="runs-sidebar-heading">History</h2>
if len(d.History) == 0 {
<p class="runs-sidebar-empty">No runs yet.</p>
} else {
<ul class="runs-sidebar-list">
for _, r := range d.History {
<li class={ "runs-sidebar-item", "runs-sidebar-" + tileMood(&r), runSidebarActiveClass(d.Tile.Latest, r.ID) }>
<a href={ templ.SafeURL(fmt.Sprintf("/hosts/%d?run=%d", d.Tile.Host.ID, r.ID)) }>
<span class={ "runs-sidebar-dot", "runs-sidebar-dot-" + tileMood(&r) }>{ runSidebarGlyph(&r) }</span>
<span class="runs-sidebar-id">{ fmt.Sprintf("#%d", r.ID) }</span>
<span class="runs-sidebar-started">{ relativeTime(r.StartedAt) }</span>
<span class="runs-sidebar-duration">{ runDuration(&r) }</span>
</a>
</li>
}
</ul>
}
</aside>
}
// RenderDetailSummaryString, RenderDetailActionsString,
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
// component to a string so the orchestrator can publish SSE fragments
// without importing the HTTP layer. Matches the RenderTileString /
// RenderPipelineString pattern.
func RenderDetailSummaryString(d HostDetailData) string {
var buf bytes.Buffer
_ = DetailSummary(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderDetailActionsString(d HostDetailData) string {
var buf bytes.Buffer
_ = DetailActions(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderDetailSpecDiffsString(d HostDetailData) string {
var buf bytes.Buffer
_ = DetailSpecDiffs(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderDetailHoldString(d HostDetailData) string {
var buf bytes.Buffer
_ = DetailHold(d).Render(context.Background(), &buf)
return buf.String()
}
// hasCriticalDiff opens the spec-diff <details> by default when any
// diff is critical — operator shouldn't have to click to see the blocker.
func hasCriticalDiff(diffs []model.SpecDiff) bool {
for _, d := range diffs {
if d.Severity == "critical" && !d.Ignored {
return true
}
}
return false
}
// stageForName returns the persisted Stage row for a given name, or a
// synthetic pending-state stub when no row has been seeded yet (e.g.
// the run is still in a pre-stage). Keeps the template free of nil
// checks and ghost logic — ActiveStep always gets a concrete Stage.
func stageForName(stages []model.Stage, name string) model.Stage {
for _, s := range stages {
if s.Name == name {
return s
}
}
return model.Stage{Name: name, State: model.StagePending}
}
// runSidebarActiveClass marks the row for the currently-viewed run so
// CSS can highlight it. Empty string (no class added) when the row isn't
// the active one.
func runSidebarActiveClass(viewed *model.Run, rowID int64) string {
if viewed != nil && viewed.ID == rowID {
return "runs-sidebar-active"
}
return ""
}
// runDuration formats the elapsed time for a run using the same buckets
// as stageDuration. In-flight runs clock from StartedAt to now so the
// header duration keeps updating on each SSE tick.
func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() {
return ""
}
end := time.Now()
if r.CompletedAt != nil {
end = *r.CompletedAt
}
d := end.Sub(r.StartedAt)
if d < 0 {
d = 0
}
switch {
case d < time.Second:
return fmt.Sprintf("%dms", int(d/time.Millisecond))
case d < 10*time.Second:
return fmt.Sprintf("%.1fs", d.Seconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
default:
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
}
}
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago"
// for the runs-sidebar. Future times (clock skew on the host) render as
// "now" so the sidebar never shows nonsense.
func relativeTime(t time.Time) string {
if t.IsZero() {
return ""
}
d := time.Since(t)
if d < 0 {
return "now"
}
if d < time.Minute {
return "just now"
}
if d < time.Hour {
return fmt.Sprintf("%dm ago", int(d/time.Minute))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh ago", int(d/time.Hour))
}
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
}
// runSidebarGlyph mirrors stageMarker for run-state: ✓ / ! / ● / .
// Used inside the sidebar dot so the color + glyph carry redundant
// meaning.
func runSidebarGlyph(r *model.Run) string {
if r == nil {
return ""
}
switch r.State {
case model.StateCompleted:
return "✓"
case model.StateFailed, model.StateFailedHolding:
return "!"
case model.StateReleased, model.StateCancelled:
return ""
}
if r.State.IsTerminal() {
return ""
}
return "●"
}
File diff suppressed because it is too large Load Diff
-117
View File
@@ -1,117 +0,0 @@
package templates
import (
"strings"
"testing"
"vetting/internal/model"
)
// TestDetailSummary_RootAttrs asserts the root <header> carries the
// stable id and sse-swap target. Successive SSE swaps replace the
// outer element, so without these attributes the second swap would
// have nothing to target.
func TestDetailSummary_RootAttrs(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7, Name: "alpha", MAC: "aa:bb:cc:dd:ee:ff"},
},
}
html := RenderDetailSummaryString(d)
for _, want := range []string{
`id="detail-summary-7"`,
`sse-swap="detail-summary-7"`,
`hx-swap="outerHTML"`,
} {
if !strings.Contains(html, want) {
t.Errorf("DetailSummary missing %q in:\n%s", want, html)
}
}
}
func TestDetailActions_RootAttrs(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7, Name: "alpha", MAC: "aa:bb:cc:dd:ee:ff"},
},
}
html := RenderDetailActionsString(d)
for _, want := range []string{
`id="detail-actions-7"`,
`sse-swap="detail-actions-7"`,
`hx-swap="outerHTML"`,
} {
if !strings.Contains(html, want) {
t.Errorf("DetailActions missing %q in:\n%s", want, html)
}
}
}
// TestDetailSpecDiffs_EmptyWrapper: when a run exists but has no diffs,
// the <section> wrapper still renders so a later SSE push has a target.
// Without this, the very first SpecValidate diff write would have no
// DOM element to swap into.
func TestDetailSpecDiffs_EmptyWrapper(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7},
Latest: &model.Run{ID: 42},
},
}
html := RenderDetailSpecDiffsString(d)
for _, want := range []string{
`id="detail-specdiffs-42"`,
`sse-swap="detail-specdiffs-42"`,
} {
if !strings.Contains(html, want) {
t.Errorf("DetailSpecDiffs missing %q in empty state:\n%s", want, html)
}
}
if strings.Contains(html, "<details") {
t.Errorf("DetailSpecDiffs empty state must not render <details>:\n%s", html)
}
}
// TestDetailHold_EmptyWrapper: same rationale as specdiffs — the
// section wrapper is always present when a run exists so the first
// hold event has a target.
func TestDetailHold_EmptyWrapper(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7},
Latest: &model.Run{ID: 42, State: model.StateInventoryCheck},
},
}
html := RenderDetailHoldString(d)
for _, want := range []string{
`id="detail-hold-42"`,
`sse-swap="detail-hold-42"`,
} {
if !strings.Contains(html, want) {
t.Errorf("DetailHold missing %q in empty state:\n%s", want, html)
}
}
if strings.Contains(html, "SSH available") {
t.Errorf("DetailHold non-holding state must not render SSH block:\n%s", html)
}
}
// TestDetailHold_HoldingRendersSSH: once the run enters FailedHolding
// with an IP, the wrapper renders the ssh invocation.
func TestDetailHold_HoldingRendersSSH(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7},
HoldKeyPath: "/tmp/hold.key",
Latest: &model.Run{
ID: 42,
State: model.StateFailedHolding,
HoldIP: "10.0.0.7",
},
},
}
html := RenderDetailHoldString(d)
if !strings.Contains(html, "ssh -i /tmp/hold.key root@10.0.0.7") {
t.Errorf("DetailHold missing ssh invocation:\n%s", html)
}
}
+365
View File
@@ -0,0 +1,365 @@
package templates
import (
"bytes"
"context"
"fmt"
"time"
"vetting/internal/model"
"vetting/internal/store"
)
// HostPageData is the payload HostPage renders. Host + LastSeenAt drive
// the summary drawer; Runs is the full newest-first run list for this
// host; ActiveRun is the non-terminal run (if any) that fills the sticky
// in-flight banner and highlights one row in the runs table; RunStages
// maps runID → stage rows so each row can paint its own 9-dot strip
// without a per-render query ladder in the template.
type HostPageData struct {
Host model.Host
LastSeenAt *time.Time
Runs []model.Run
ActiveRun *model.Run
RunStages map[int64][]model.Stage
}
// HostPage is the host-focused URL: summary + actions + in-flight banner
// + runs table. Everything run-specific (pipeline, logs, sub-steps, spec
// diffs, hold banner) lives on /runs/{runID} instead. SSE targets are
// scoped per region so live tile refreshes don't reflow the whole page.
templ HostPage(d HostPageData) {
@Layout(d.Host.Name) {
<section class="host-page" hx-ext="sse" sse-connect="/events">
<nav class="breadcrumb">
<a href="/">Dashboard</a>
<span class="breadcrumb-sep">/</span>
<span>{ d.Host.Name }</span>
</nav>
@HostSummary(d)
@HostActions(d)
@InFlightBanner(d)
if len(d.Runs) == 0 {
@HostEmptyState(d)
} else {
@RunsTable(d)
}
</section>
}
}
// HostSummary is the compact meta card at the top of the host page:
// hostname, last-seen chip, MAC, WoL target, expected spec (collapsed).
// SSE-swap target so an operator edit / heartbeat arriving mid-view
// updates the card without a reload.
templ HostSummary(d HostPageData) {
<section
id={ fmt.Sprintf("detail-summary-%d", d.Host.ID) }
class="host-summary"
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Host.ID) }
hx-swap="outerHTML"
>
<div class="host-summary-head">
<h1 class="host-summary-name">{ d.Host.Name }</h1>
<span class={ "tile-last-seen", lastSeenClass(d.LastSeenAt) }>{ lastSeenLabel(d.LastSeenAt) }</span>
</div>
<dl class="host-summary-meta">
<div>
<dt>MAC</dt>
<dd>{ d.Host.MAC }</dd>
</div>
<div>
<dt>WoL</dt>
<dd>{ fmt.Sprintf("%s:%d", d.Host.WoLBroadcastIP, d.Host.WoLPort) }</dd>
</div>
</dl>
if d.Host.Notes != "" {
<div class="host-summary-notes">
<h3>Notes</h3>
<p>{ d.Host.Notes }</p>
</div>
}
<details class="host-summary-spec">
<summary>Expected spec</summary>
<pre class="host-summary-spec-yaml">{ d.Host.ExpectedSpecYAML }</pre>
</details>
</section>
}
// HostActions is the primary-action row: Start vetting (enabled only when
// no active run AND host is heartbeating), Delete host. Run-level actions
// (Cancel / Override / View report) live on the run page — the host page
// only exposes things scoped to the host itself.
templ HostActions(d HostPageData) {
<section
id={ fmt.Sprintf("detail-actions-%d", d.Host.ID) }
class="host-actions"
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Host.ID) }
hx-swap="outerHTML"
>
<div class="host-actions-row">
if hostCanStart(d) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline host-start-form">
<label class="host-nd-toggle">
<input type="checkbox" name="non_destructive" value="1"/>
Non-destructive (skip wipe-probe + disk writes)
</label>
<button type="submit" class="btn-primary">Start vetting</button>
</form>
} else if hostCanStartIfOnline(d) {
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
} else {
<button type="button" disabled>Run in flight</button>
}
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)) } class="inline" onsubmit="return confirm('Delete host and all its runs?');">
<button type="submit" class="btn-danger">Delete host</button>
</form>
</div>
</section>
}
// InFlightBanner is the sticky "Run #N in progress — open →" strip that
// shows only when an active (non-terminal) run exists. SSE target so a
// run starting or ending flips the banner live.
templ InFlightBanner(d HostPageData) {
<section
id={ fmt.Sprintf("detail-inflight-%d", d.Host.ID) }
class="in-flight-banner-wrap"
sse-swap={ fmt.Sprintf("detail-inflight-%d", d.Host.ID) }
hx-swap="outerHTML"
>
if d.ActiveRun != nil {
<a class="in-flight-banner" href={ templ.SafeURL(fmt.Sprintf("/runs/%d", d.ActiveRun.ID)) }>
<span class="in-flight-label">Run #{ fmt.Sprintf("%d", d.ActiveRun.ID) } in progress —</span>
<span class="in-flight-state">{ tileStatus(d.ActiveRun) }</span>
<span class="in-flight-open">open →</span>
</a>
}
</section>
}
// HostEmptyState replaces the runs table with a big call-to-action when
// this host has never had a run. Only renders when the host is both
// reachable AND has no runs — the standard "Run in flight"-ish disabled
// button from HostActions handles the other corners.
templ HostEmptyState(d HostPageData) {
<section class="host-empty-state">
<p class="host-empty-title">No runs yet.</p>
<p class="host-empty-sub">Kick off the first vetting run whenever the host is heartbeating.</p>
if hostCanStart(d) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline">
<button type="submit" class="btn-primary big">Start vetting</button>
</form>
} else {
<button type="button" class="btn-primary big" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
}
</section>
}
// RunsTable is one row per run, newest first. Each row carries its own
// SSE-swap target so live state changes (a running row flipping to
// passed) update one <tr> without re-rendering the whole table.
templ RunsTable(d HostPageData) {
<section class="host-runs">
<h2>Runs</h2>
<table class="runs-table">
<thead>
<tr>
<th>Run</th>
<th>State</th>
<th>Started</th>
<th>Duration</th>
<th>Stages</th>
<th></th>
</tr>
</thead>
<tbody>
for _, r := range d.Runs {
@RunRow(RunRowData{
Run: r,
Stages: d.RunStages[r.ID],
Live: d.ActiveRun != nil && d.ActiveRun.ID == r.ID,
})
}
</tbody>
</table>
</section>
}
// RunRowData is a single row's payload. Live is true for the currently
// non-terminal run so CSS can highlight it at the top of the table.
type RunRowData struct {
Run model.Run
Stages []model.Stage
Live bool
}
// RunRow renders one <tr> keyed by runrow-{runID}. State changes fire
// runrow-{runID} from the orchestrator so the single row re-renders with
// its updated state + stage-strip without reloading the host page.
templ RunRow(d RunRowData) {
<tr
id={ fmt.Sprintf("runrow-%d", d.Run.ID) }
class={ "runs-row", "runs-row-" + tileMood(&d.Run), runRowLiveClass(d.Live) }
sse-swap={ fmt.Sprintf("runrow-%d", d.Run.ID) }
hx-swap="outerHTML"
>
<td class="runs-col-id">
<a href={ templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)) }>{ fmt.Sprintf("#%d", d.Run.ID) }</a>
</td>
<td class="runs-col-state">
<span class={ "run-status-badge", "run-status-" + tileMood(&d.Run) }>{ tileStatus(&d.Run) }</span>
</td>
<td class="runs-col-started">{ relativeTime(d.Run.StartedAt) }</td>
<td class="runs-col-duration">{ runDuration(&d.Run) }</td>
<td class="runs-col-strip">
<div class="stage-strip">
for _, name := range store.DefaultStageOrder {
{{ st := stageForName(d.Stages, name) }}
<span class={ "stage-dot", "stage-dot-sm", "stage-dot-" + string(st.State) } title={ name }></span>
}
</div>
</td>
<td class="runs-col-open">
<a class="runs-open-link" href={ templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)) }>open →</a>
</td>
</tr>
}
// runRowLiveClass tags the currently non-terminal run so CSS can
// highlight it. Empty string for every other row.
func runRowLiveClass(live bool) string {
if live {
return "runs-row-live"
}
return ""
}
// hostCanStart is the host-page analogue of canStart. Guards the Start
// button on two things: there's no active run, AND the host is currently
// heartbeating. Mirrors the StartRun handler's preflight so the button
// never offers a click the server rejects.
func hostCanStart(d HostPageData) bool {
if !hostCanStartIfOnline(d) {
return false
}
if d.LastSeenAt == nil {
return false
}
return time.Since(*d.LastSeenAt) <= 60*time.Second
}
// hostCanStartIfOnline is the run-state half of hostCanStart, split out
// so HostActions can distinguish "run in flight" (no button) from "run
// is done / no run yet but host is offline" (disabled button).
func hostCanStartIfOnline(d HostPageData) bool {
return d.ActiveRun == nil
}
// runDuration formats the elapsed time for a run using the same buckets
// as stageDuration. In-flight runs clock from StartedAt to now so the
// run-page header + runs-table row keep ticking on each SSE push.
func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() {
return ""
}
end := time.Now()
if r.CompletedAt != nil {
end = *r.CompletedAt
}
d := end.Sub(r.StartedAt)
if d < 0 {
d = 0
}
switch {
case d < time.Second:
return fmt.Sprintf("%dms", int(d/time.Millisecond))
case d < 10*time.Second:
return fmt.Sprintf("%.1fs", d.Seconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
default:
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
}
}
// stageForName returns the persisted Stage row for a given name, or a
// synthetic pending-state stub when no row has been seeded yet (e.g.
// a run still in a pre-stage). Keeps the template free of nil checks —
// the caller always gets a concrete Stage.
func stageForName(stages []model.Stage, name string) model.Stage {
for _, s := range stages {
if s.Name == name {
return s
}
}
return model.Stage{Name: name, State: model.StagePending}
}
// hasCriticalDiff opens the spec-diff <details> by default when any diff
// is critical — operator shouldn't have to click to see the blocker.
func hasCriticalDiff(diffs []model.SpecDiff) bool {
for _, d := range diffs {
if d.Severity == "critical" && !d.Ignored {
return true
}
}
return false
}
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago".
// Future times (clock skew) render as "now" so the runs table never
// shows nonsense when a host's clock is ahead of the orchestrator.
func relativeTime(t time.Time) string {
if t.IsZero() {
return ""
}
d := time.Since(t)
if d < 0 {
return "now"
}
if d < time.Minute {
return "just now"
}
if d < time.Hour {
return fmt.Sprintf("%dm ago", int(d/time.Minute))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh ago", int(d/time.Hour))
}
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
}
// RenderHostSummaryString, RenderHostActionsString, and
// RenderInFlightBannerString render one region to a string for the
// orchestrator's SSE publish path. Matches the RenderTileString pattern.
func RenderHostSummaryString(d HostPageData) string {
var buf bytes.Buffer
_ = HostSummary(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderHostActionsString(d HostPageData) string {
var buf bytes.Buffer
_ = HostActions(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderInFlightBannerString(d HostPageData) string {
var buf bytes.Buffer
_ = InFlightBanner(d).Render(context.Background(), &buf)
return buf.String()
}
// RenderRunRowString renders one row for the runs table over SSE when
// a run's state changes. The orchestrator fires runrow-{runID} at every
// site that already fires tile-{hostID} + pipeline-{runID}.
func RenderRunRowString(d RunRowData) string {
var buf bytes.Buffer
_ = RunRow(d).Render(context.Background(), &buf)
return buf.String()
}
+976
View File
@@ -0,0 +1,976 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"bytes"
"context"
"fmt"
"time"
"vetting/internal/model"
"vetting/internal/store"
)
// HostPageData is the payload HostPage renders. Host + LastSeenAt drive
// the summary drawer; Runs is the full newest-first run list for this
// host; ActiveRun is the non-terminal run (if any) that fills the sticky
// in-flight banner and highlights one row in the runs table; RunStages
// maps runID → stage rows so each row can paint its own 9-dot strip
// without a per-render query ladder in the template.
type HostPageData struct {
Host model.Host
LastSeenAt *time.Time
Runs []model.Run
ActiveRun *model.Run
RunStages map[int64][]model.Stage
}
// HostPage is the host-focused URL: summary + actions + in-flight banner
// + runs table. Everything run-specific (pipeline, logs, sub-steps, spec
// diffs, hold banner) lives on /runs/{runID} instead. SSE targets are
// scoped per region so live tile refreshes don't reflow the whole page.
func HostPage(d HostPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"host-page\" hx-ext=\"sse\" sse-connect=\"/events\"><nav class=\"breadcrumb\"><a href=\"/\">Dashboard</a> <span class=\"breadcrumb-sep\">/</span> <span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 37, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = HostSummary(d).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = HostActions(d).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = InFlightBanner(d).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.Runs) == 0 {
templ_7745c5c3_Err = HostEmptyState(d).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = RunsTable(d).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout(d.Host.Name).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// HostSummary is the compact meta card at the top of the host page:
// hostname, last-seen chip, MAC, WoL target, expected spec (collapsed).
// SSE-swap target so an operator edit / heartbeat arriving mid-view
// updates the card without a reload.
func HostSummary(d HostPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var4 := templ.GetChildren(ctx)
if templ_7745c5c3_Var4 == nil {
templ_7745c5c3_Var4 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-summary-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 59, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" class=\"host-summary\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-summary-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 61, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" hx-swap=\"outerHTML\"><div class=\"host-summary-head\"><h1 class=\"host-summary-name\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 65, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 = []any{"tile-last-seen", lastSeenClass(d.LastSeenAt)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.LastSeenAt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 66, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span></div><dl class=\"host-summary-meta\"><div><dt>MAC</dt><dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.MAC)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 71, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</dd></div><div><dt>WoL</dt><dd>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Host.WoLBroadcastIP, d.Host.WoLPort))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 75, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</dd></div></dl>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Host.Notes != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"host-summary-notes\"><h3>Notes</h3><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.Notes)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 81, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<details class=\"host-summary-spec\"><summary>Expected spec</summary><pre class=\"host-summary-spec-yaml\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.ExpectedSpecYAML)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 86, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</pre></details></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// HostActions is the primary-action row: Start vetting (enabled only when
// no active run AND host is heartbeating), Delete host. Run-level actions
// (Cancel / Override / View report) live on the run page — the host page
// only exposes things scoped to the host itself.
func HostActions(d HostPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-actions-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 97, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"host-actions\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-actions-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 99, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-swap=\"outerHTML\"><div class=\"host-actions-row\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hostCanStart(d) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 104, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline host-start-form\"><label class=\"host-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive (skip wipe-probe + disk writes)</label> <button type=\"submit\" class=\"btn-primary\">Start vetting</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if hostCanStartIfOnline(d) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button type=\"button\" disabled>Run in flight</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 116, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"inline\" onsubmit=\"return confirm('Delete host and all its runs?');\"><button type=\"submit\" class=\"btn-danger\">Delete host</button></form></div></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// InFlightBanner is the sticky "Run #N in progress — open →" strip that
// shows only when an active (non-terminal) run exists. SSE target so a
// run starting or ending flips the banner live.
func InFlightBanner(d HostPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
if templ_7745c5c3_Var20 == nil {
templ_7745c5c3_Var20 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 128, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"in-flight-banner-wrap\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 130, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.ActiveRun != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<a class=\"in-flight-banner\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 templ.SafeURL
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.ActiveRun.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 134, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"><span class=\"in-flight-label\">Run #")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ActiveRun.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 135, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " in progress —</span> <span class=\"in-flight-state\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.ActiveRun))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 136, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span> <span class=\"in-flight-open\">open →</span></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// HostEmptyState replaces the runs table with a big call-to-action when
// this host has never had a run. Only renders when the host is both
// reachable AND has no runs — the standard "Run in flight"-ish disabled
// button from HostActions handles the other corners.
func HostEmptyState(d HostPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var26 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section class=\"host-empty-state\"><p class=\"host-empty-title\">No runs yet.</p><p class=\"host-empty-sub\">Kick off the first vetting run whenever the host is heartbeating.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hostCanStart(d) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 templ.SafeURL
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 152, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" class=\"inline\"><button type=\"submit\" class=\"btn-primary big\">Start vetting</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<button type=\"button\" class=\"btn-primary big\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RunsTable is one row per run, newest first. Each row carries its own
// SSE-swap target so live state changes (a running row flipping to
// passed) update one <tr> without re-rendering the whole table.
func RunsTable(d HostPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil {
templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<section class=\"host-runs\"><h2>Runs</h2><table class=\"runs-table\"><thead><tr><th>Run</th><th>State</th><th>Started</th><th>Duration</th><th>Stages</th><th></th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, r := range d.Runs {
templ_7745c5c3_Err = RunRow(RunRowData{
Run: r,
Stages: d.RunStages[r.ID],
Live: d.ActiveRun != nil && d.ActiveRun.ID == r.ID,
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</tbody></table></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RunRowData is a single row's payload. Live is true for the currently
// non-terminal run so CSS can highlight it at the top of the table.
type RunRowData struct {
Run model.Run
Stages []model.Stage
Live bool
}
// RunRow renders one <tr> keyed by runrow-{runID}. State changes fire
// runrow-{runID} from the orchestrator so the single row re-renders with
// its updated state + stage-strip without reloading the host page.
func RunRow(d RunRowData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var29 := templ.GetChildren(ctx)
if templ_7745c5c3_Var29 == nil {
templ_7745c5c3_Var29 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var30 = []any{"runs-row", "runs-row-" + tileMood(&d.Run), runRowLiveClass(d.Live)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<tr id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 204, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var30).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 206, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" hx-swap=\"outerHTML\"><td class=\"runs-col-id\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 templ.SafeURL
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 210, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 210, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</a></td><td class=\"runs-col-state\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 = []any{"run-status-badge", "run-status-" + tileMood(&d.Run)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var36...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var36).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 213, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span></td><td class=\"runs-col-started\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(relativeTime(d.Run.StartedAt))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 215, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"runs-col-duration\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 216, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</td><td class=\"runs-col-strip\"><div class=\"stage-strip\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, name := range store.DefaultStageOrder {
st := stageForName(d.Stages, name)
var templ_7745c5c3_Var41 = []any{"stage-dot", "stage-dot-sm", "stage-dot-" + string(st.State)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var41...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var41).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 221, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div></td><td class=\"runs-col-open\"><a class=\"runs-open-link\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 templ.SafeURL
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 226, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">open →</a></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// runRowLiveClass tags the currently non-terminal run so CSS can
// highlight it. Empty string for every other row.
func runRowLiveClass(live bool) string {
if live {
return "runs-row-live"
}
return ""
}
// hostCanStart is the host-page analogue of canStart. Guards the Start
// button on two things: there's no active run, AND the host is currently
// heartbeating. Mirrors the StartRun handler's preflight so the button
// never offers a click the server rejects.
func hostCanStart(d HostPageData) bool {
if !hostCanStartIfOnline(d) {
return false
}
if d.LastSeenAt == nil {
return false
}
return time.Since(*d.LastSeenAt) <= 60*time.Second
}
// hostCanStartIfOnline is the run-state half of hostCanStart, split out
// so HostActions can distinguish "run in flight" (no button) from "run
// is done / no run yet but host is offline" (disabled button).
func hostCanStartIfOnline(d HostPageData) bool {
return d.ActiveRun == nil
}
// runDuration formats the elapsed time for a run using the same buckets
// as stageDuration. In-flight runs clock from StartedAt to now so the
// run-page header + runs-table row keep ticking on each SSE push.
func runDuration(r *model.Run) string {
if r == nil || r.StartedAt.IsZero() {
return ""
}
end := time.Now()
if r.CompletedAt != nil {
end = *r.CompletedAt
}
d := end.Sub(r.StartedAt)
if d < 0 {
d = 0
}
switch {
case d < time.Second:
return fmt.Sprintf("%dms", int(d/time.Millisecond))
case d < 10*time.Second:
return fmt.Sprintf("%.1fs", d.Seconds())
case d < time.Minute:
return fmt.Sprintf("%ds", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
default:
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
}
}
// stageForName returns the persisted Stage row for a given name, or a
// synthetic pending-state stub when no row has been seeded yet (e.g.
// a run still in a pre-stage). Keeps the template free of nil checks —
// the caller always gets a concrete Stage.
func stageForName(stages []model.Stage, name string) model.Stage {
for _, s := range stages {
if s.Name == name {
return s
}
}
return model.Stage{Name: name, State: model.StagePending}
}
// hasCriticalDiff opens the spec-diff <details> by default when any diff
// is critical — operator shouldn't have to click to see the blocker.
func hasCriticalDiff(diffs []model.SpecDiff) bool {
for _, d := range diffs {
if d.Severity == "critical" && !d.Ignored {
return true
}
}
return false
}
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago".
// Future times (clock skew) render as "now" so the runs table never
// shows nonsense when a host's clock is ahead of the orchestrator.
func relativeTime(t time.Time) string {
if t.IsZero() {
return ""
}
d := time.Since(t)
if d < 0 {
return "now"
}
if d < time.Minute {
return "just now"
}
if d < time.Hour {
return fmt.Sprintf("%dm ago", int(d/time.Minute))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh ago", int(d/time.Hour))
}
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
}
// RenderHostSummaryString, RenderHostActionsString, and
// RenderInFlightBannerString render one region to a string for the
// orchestrator's SSE publish path. Matches the RenderTileString pattern.
func RenderHostSummaryString(d HostPageData) string {
var buf bytes.Buffer
_ = HostSummary(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderHostActionsString(d HostPageData) string {
var buf bytes.Buffer
_ = HostActions(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderInFlightBannerString(d HostPageData) string {
var buf bytes.Buffer
_ = InFlightBanner(d).Render(context.Background(), &buf)
return buf.String()
}
// RenderRunRowString renders one row for the runs table over SSE when
// a run's state changes. The orchestrator fires runrow-{runID} at every
// site that already fires tile-{hostID} + pipeline-{runID}.
func RenderRunRowString(d RunRowData) string {
var buf bytes.Buffer
_ = RunRow(d).Render(context.Background(), &buf)
return buf.String()
}
var _ = templruntime.GeneratedTemplate
+5 -30
View File
@@ -7,16 +7,13 @@ import (
"time" "time"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/store"
) )
// HostTile renders a single dashboard card as a mini run-view. The whole // HostTile renders a single dashboard card: hostname, heartbeat badge,
// tile is a link to /hosts/{id} (via a CSS-overlay <a>) — every control // latest run status, and the primary action (Start / Cancel / View
// beyond the one primary action lives on the detail page. It's the SSE- // report). The whole tile is a link to /hosts/{id} via a CSS-overlay
// swap target for per-host tile refreshes (`tile-N`). The step list is // <a>; every deeper control lives on the host page or the run page.
// a compact vertical strip of the 9 canonical stages with just a // It's the SSE-swap target for per-host tile refreshes (`tile-N`).
// coloured dot per stage; operators can read run health at a glance
// across the whole dashboard without drilling in.
templ HostTile(t TileData) { templ HostTile(t TileData) {
<article <article
id={ fmt.Sprintf("host-%d", t.Host.ID) } id={ fmt.Sprintf("host-%d", t.Host.ID) }
@@ -32,17 +29,6 @@ templ HostTile(t TileData) {
<div class="tile-status">{ tileStatus(t.Latest) }</div> <div class="tile-status">{ tileStatus(t.Latest) }</div>
</div> </div>
</header> </header>
if t.Latest != nil {
<div class="tile-meta-row">
<span class="tile-run-id">{ fmt.Sprintf("#%d", t.Latest.ID) }</span>
<span class="tile-run-duration">{ runDuration(t.Latest) }</span>
</div>
}
<ol class="tile-steplist">
for _, name := range store.DefaultStageOrder {
@tileStep(stageForName(t.Stages, name))
}
</ol>
<div class="tile-primary-action"> <div class="tile-primary-action">
if canStart(t) { if canStart(t) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form"> <form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
@@ -65,17 +51,6 @@ templ HostTile(t TileData) {
</article> </article>
} }
// tileStep renders one entry of the tile's mini step-list: a small
// coloured dot plus the short stage name. Kept as its own templ so the
// markup stays consistent with the detail page's larger stage-dot
// elements (same class prefix, different size via the `-sm` modifier).
templ tileStep(s model.Stage) {
<li class={ "tile-step", "tile-step-" + string(s.State) }>
<span class={ "stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State) }>{ stageMarker(string(s.State)) }</span>
<span class="tile-step-name">{ s.Name }</span>
</li>
}
func canOverrideWipe(r *model.Run) bool { func canOverrideWipe(r *model.Run) bool {
if r == nil { if r == nil {
return false return false
+39 -187
View File
@@ -15,16 +15,13 @@ import (
"time" "time"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/store"
) )
// HostTile renders a single dashboard card as a mini run-view. The whole // HostTile renders a single dashboard card: hostname, heartbeat badge,
// tile is a link to /hosts/{id} (via a CSS-overlay <a>) — every control // latest run status, and the primary action (Start / Cancel / View
// beyond the one primary action lives on the detail page. It's the SSE- // report). The whole tile is a link to /hosts/{id} via a CSS-overlay
// swap target for per-host tile refreshes (`tile-N`). The step list is // <a>; every deeper control lives on the host page or the run page.
// a compact vertical strip of the 9 canonical stages with just a // It's the SSE-swap target for per-host tile refreshes (`tile-N`).
// coloured dot per stage; operators can read run health at a glance
// across the whole dashboard without drilling in.
func HostTile(t TileData) templ.Component { func HostTile(t TileData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -58,7 +55,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -84,7 +81,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 21, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -97,7 +94,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var6 templ.SafeURL var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID))) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 80} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 80}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -110,7 +107,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 117} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 117}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -123,7 +120,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 39} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 39}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -158,7 +155,7 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt)) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 31, Col: 95} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 28, Col: 95}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -171,222 +168,77 @@ func HostTile(t TileData) templ.Component {
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest)) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></header>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></header><div class=\"tile-primary-action\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if t.Latest != nil { if canStart(t) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"tile-meta-row\"><span class=\"tile-run-id\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 templ.SafeURL
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", t.Latest.ID)) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 37, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 34, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> <span class=\"tile-run-duration\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string } else if canStartIfOnline(t.Latest) {
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(t.Latest)) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 38, Col: 59} return templ_7745c5c3_Err
}
} else if canCancel(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 44, Col: 90}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} } else if hasReport(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<ol class=\"tile-steplist\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a class=\"button-like\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, name := range store.DefaultStageOrder {
templ_7745c5c3_Err = tileStep(stageForName(t.Stages, name)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</ol><div class=\"tile-primary-action\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canStart(t) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 templ.SafeURL var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID))) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 88}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if canStartIfOnline(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if canCancel(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 templ.SafeURL
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 58, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if hasReport(t.Latest) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a class=\"button-like\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 templ.SafeURL
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 62, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></article>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// tileStep renders one entry of the tile's mini step-list: a small
// coloured dot plus the short stage name. Kept as its own templ so the
// markup stays consistent with the detail page's larger stage-dot
// elements (same class prefix, different size via the `-sm` modifier).
func tileStep(s model.Stage) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
if templ_7745c5c3_Var18 == nil {
templ_7745c5c3_Var18 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var19 = []any{"tile-step", "tile-step-" + string(s.State)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var19).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 = []any{"stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var21).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(s.State)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 74, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span> <span class=\"tile-step-name\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 75, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></li>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+16 -76
View File
@@ -2,13 +2,11 @@ package templates
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
"vetting/internal/model" "vetting/internal/model"
"vetting/internal/store"
) )
func TestHumanAgoFrom(t *testing.T) { 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 // TestHostTile_NoStageStrip: the tile no longer carries the Phase 3
// for every canonical stage, colours the dots according to the mixed // per-stage mini run-view — the runs-table on /hosts/{id} owns the
// stage states in the fixture, and surfaces the run id + duration in // stage-strip now. Guards against the regression that would bring
// the meta row. This is the contract the dashboard leans on: the // `tile-steplist` / `tile-step-name` / `tile-run-duration` back.
// operator should be able to read run health across all tiles without func TestHostTile_NoStageStrip(t *testing.T) {
// drilling into any of them.
func TestHostTile_MiniRunView(t *testing.T) {
now := time.Now() now := time.Now()
started := now.Add(-3 * time.Minute)
latest := &model.Run{ latest := &model.Run{
ID: 17, ID: 17,
State: model.StateSMART, State: model.StateSMART,
StartedAt: started, StartedAt: now.Add(-3 * time.Minute),
}
// 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},
} }
data := TileData{ data := TileData{
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
Latest: latest, Latest: latest,
Stages: stages,
LastSeenAt: &now, LastSeenAt: &now,
} }
var buf strings.Builder var buf strings.Builder
@@ -129,66 +117,18 @@ func TestHostTile_MiniRunView(t *testing.T) {
t.Fatalf("render: %v", err) t.Fatalf("render: %v", err)
} }
html := buf.String() html := buf.String()
for _, dropped := range []string{
// Step list exists and contains every canonical stage name so the `tile-steplist`,
// operator reads a full 9-dot strip regardless of how far the run got. `tile-step-name`,
if !strings.Contains(html, `<ol class="tile-steplist">`) { `tile-step-dot`,
t.Fatalf("tile missing step list: %s", html) `tile-run-id`,
} `tile-run-duration`,
for _, s := range store.DefaultStageOrder { `tile-meta-row`,
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, s) } {
if !strings.Contains(html, want) { if strings.Contains(html, dropped) {
t.Fatalf("tile missing step name %q: %s", s, html) 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) { func TestLastSeenLabelAndClass(t *testing.T) {
+188
View File
@@ -0,0 +1,188 @@
package templates
import (
"bytes"
"context"
"fmt"
"vetting/internal/model"
"vetting/internal/store"
)
// RunPageData is the full payload for /runs/{runID}. Host is resolved
// from Run.HostID so the breadcrumb + run actions (which post to
// /hosts/{hostID}/...) have the host context without a separate call.
// Stages/SubSteps/SpecDiffs drive the pipeline + active-step panels +
// diff list. DefaultStepStage is the stage name whose <details> opens
// on first render — running → failed → Reporting. HoldKeyPath is the
// on-disk path of the hold_key artifact, needed to print the ssh
// invocation in the hold banner. SpecDiffCritical is the count of
// unignored critical diffs shown in the header.
type RunPageData struct {
Host model.Host
Run model.Run
Stages []model.Stage
SubSteps []model.SubStep
SpecDiffs []model.SpecDiff
DefaultStepStage string
LogReplayByStage map[string]string
HoldKeyPath string
SpecDiffCritical int
}
// RunPage is the run-focused URL: pipeline + per-stage active-step panels
// + spec diffs + hold banner. Host metadata stays on /hosts/{id}; this
// page carries only run-scoped content so the operator can read one run
// without surrounding noise.
templ RunPage(d RunPageData) {
@Layout(fmt.Sprintf("%s — run #%d", d.Host.Name, d.Run.ID)) {
<section class="run-page" hx-ext="sse" sse-connect="/events">
<nav class="breadcrumb">
<a href="/">Dashboard</a>
<span class="breadcrumb-sep">/</span>
<a href={ templ.SafeURL(fmt.Sprintf("/hosts/%d", d.Host.ID)) }>{ d.Host.Name }</a>
<span class="breadcrumb-sep">/</span>
<span>{ fmt.Sprintf("run #%d", d.Run.ID) }</span>
</nav>
@RunHeader(d)
@HoldBanner(d)
@PipelineSection(&d.Run, BuildPipeline(&d.Run, d.Stages))
<div class="run-body">
<div class="active-step-pane">
for _, stageName := range store.DefaultStageOrder {
@ActiveStep(ActiveStepData{
RunID: d.Run.ID,
Stage: stageForName(d.Stages, stageName),
SubSteps: SubStepsForStage(d.SubSteps, stageName),
LogReplay: d.LogReplayByStage[stageName],
Open: stageName == d.DefaultStepStage,
})
}
</div>
</div>
@RunSpecDiffs(d)
</section>
}
}
// RunHeader is the run-page header: run id, state badge, elapsed, and
// the primary action on the right (Cancel during a non-terminal run;
// Start-new-run + View-report after). Keyed on run ID so SSE updates
// don't collide with a newer run's header. Rendered as a section rather
// than a bare header so it composes with the breadcrumb strip above.
templ RunHeader(d RunPageData) {
<header
id={ fmt.Sprintf("run-header-%d", d.Run.ID) }
class={ "run-header", "tile-" + tileMood(&d.Run) }
sse-swap={ fmt.Sprintf("run-header-%d", d.Run.ID) }
hx-swap="outerHTML"
>
<div class="run-header-left">
<h1 class="run-header-name">{ fmt.Sprintf("Run #%d", d.Run.ID) }</h1>
<span class={ "run-status-badge", "run-status-" + tileMood(&d.Run) }>{ tileStatus(&d.Run) }</span>
<span class="run-duration">{ runDuration(&d.Run) }</span>
if d.Run.FailedStage != "" {
<span class="run-failed-stage">failed at <strong>{ d.Run.FailedStage }</strong></span>
}
if d.SpecDiffCritical > 0 {
<span class="run-diffs bad">{ fmt.Sprintf("%d critical diff", d.SpecDiffCritical) }</span>
}
</div>
<div class="run-header-right">
if canCancel(&d.Run) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
<button type="submit" class="btn-danger">Cancel run</button>
</form>
}
if canOverrideWipe(&d.Run) {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Host.ID)) } class="inline">
<button type="submit" class="btn-danger">Override wipe-probe</button>
</form>
}
if hasReport(&d.Run) {
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Run.ID)) } target="_blank" rel="noopener">View report</a>
}
if d.Run.State.IsTerminal() {
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline">
<button type="submit" class="btn-primary">Start new run</button>
</form>
}
</div>
</header>
}
// HoldBanner is the "Host is holding — SSH available" strip when a run
// is FailedHolding with an IP recorded. Emits an empty placeholder
// otherwise so the first SSE push when a hold actually fires has a
// target to swap into.
templ HoldBanner(d RunPageData) {
if d.Run.State == model.StateFailedHolding && d.Run.HoldIP != "" {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Run.ID) }
class="hold-banner"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Run.ID) }
hx-swap="outerHTML"
>
<span class="hold-banner-label">Host is holding — SSH available:</span>
<code class="hold-ssh">{ sshInvocation(d.HoldKeyPath, d.Run.HoldIP) }</code>
</section>
} else {
<section
id={ fmt.Sprintf("detail-hold-%d", d.Run.ID) }
class="detail-hold-placeholder"
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Run.ID) }
hx-swap="outerHTML"
></section>
}
}
// RunSpecDiffs renders the "Spec diffs (N)" collapsible. The wrapper is
// always emitted (even when empty) so SpecValidate-time SSE pushes have
// a target; the <details> body only renders when diffs exist.
templ RunSpecDiffs(d RunPageData) {
<section
id={ fmt.Sprintf("detail-specdiffs-%d", d.Run.ID) }
class="detail-section detail-diffs"
sse-swap={ fmt.Sprintf("detail-specdiffs-%d", d.Run.ID) }
hx-swap="outerHTML"
>
if len(d.SpecDiffs) > 0 {
<details open?={ hasCriticalDiff(d.SpecDiffs) }>
<summary><h2>Spec diffs ({ fmt.Sprintf("%d", len(d.SpecDiffs)) })</h2></summary>
<ul class="diff-list">
for _, diff := range d.SpecDiffs {
<li class={ "diff-row", "diff-" + diff.Severity }>
<div class="diff-field">{ diff.Field }</div>
<div class="diff-expected">expected: <code>{ diff.Expected }</code></div>
<div class="diff-actual">actual: <code>{ diff.Actual }</code></div>
</li>
}
</ul>
</details>
}
</section>
}
// RenderRunHeaderString, RenderHoldBannerString, and
// RenderRunSpecDiffsString render each region to a string for the
// orchestrator's SSE publish path. Matches the RenderTileString pattern.
func RenderRunHeaderString(d RunPageData) string {
var buf bytes.Buffer
_ = RunHeader(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderHoldBannerString(d RunPageData) string {
var buf bytes.Buffer
_ = HoldBanner(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderRunSpecDiffsString(d RunPageData) string {
var buf bytes.Buffer
_ = RunSpecDiffs(d).Render(context.Background(), &buf)
return buf.String()
}
+716
View File
@@ -0,0 +1,716 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import (
"bytes"
"context"
"fmt"
"vetting/internal/model"
"vetting/internal/store"
)
// RunPageData is the full payload for /runs/{runID}. Host is resolved
// from Run.HostID so the breadcrumb + run actions (which post to
// /hosts/{hostID}/...) have the host context without a separate call.
// Stages/SubSteps/SpecDiffs drive the pipeline + active-step panels +
// diff list. DefaultStepStage is the stage name whose <details> opens
// on first render — running → failed → Reporting. HoldKeyPath is the
// on-disk path of the hold_key artifact, needed to print the ssh
// invocation in the hold banner. SpecDiffCritical is the count of
// unignored critical diffs shown in the header.
type RunPageData struct {
Host model.Host
Run model.Run
Stages []model.Stage
SubSteps []model.SubStep
SpecDiffs []model.SpecDiff
DefaultStepStage string
LogReplayByStage map[string]string
HoldKeyPath string
SpecDiffCritical int
}
// RunPage is the run-focused URL: pipeline + per-stage active-step panels
// + spec diffs + hold banner. Host metadata stays on /hosts/{id}; this
// page carries only run-scoped content so the operator can read one run
// without surrounding noise.
func RunPage(d RunPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"run-page\" hx-ext=\"sse\" sse-connect=\"/events\"><nav class=\"breadcrumb\"><a href=\"/\">Dashboard</a> <span class=\"breadcrumb-sep\">/</span> <a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 43, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Host.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 43, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</a> <span class=\"breadcrumb-sep\">/</span> <span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("run #%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 45, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = RunHeader(d).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = HoldBanner(d).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PipelineSection(&d.Run, BuildPipeline(&d.Run, d.Stages)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"run-body\"><div class=\"active-step-pane\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, stageName := range store.DefaultStageOrder {
templ_7745c5c3_Err = ActiveStep(ActiveStepData{
RunID: d.Run.ID,
Stage: stageForName(d.Stages, stageName),
SubSteps: SubStepsForStage(d.SubSteps, stageName),
LogReplay: d.LogReplayByStage[stageName],
Open: stageName == d.DefaultStepStage,
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = RunSpecDiffs(d).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout(fmt.Sprintf("%s — run #%d", d.Host.Name, d.Run.ID)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RunHeader is the run-page header: run id, state badge, elapsed, and
// the primary action on the right (Cancel during a non-terminal run;
// Start-new-run + View-report after). Keyed on run ID so SSE updates
// don't collide with a newer run's header. Rendered as a section rather
// than a bare header so it composes with the breadcrumb strip above.
func RunHeader(d RunPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var7 = []any{"run-header", "tile-" + tileMood(&d.Run)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<header id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("run-header-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 78, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("run-header-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 80, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" hx-swap=\"outerHTML\"><div class=\"run-header-left\"><h1 class=\"run-header-name\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Run #%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 84, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 = []any{"run-status-badge", "run-status-" + tileMood(&d.Run)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var12).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 85, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span> <span class=\"run-duration\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 86, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if d.Run.FailedStage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"run-failed-stage\">failed at <strong>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Run.FailedStage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 88, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</strong></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.SpecDiffCritical > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"run-diffs bad\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical diff", d.SpecDiffCritical))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 91, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div class=\"run-header-right\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if canCancel(&d.Run) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 96, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"inline\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"btn-danger\">Cancel run</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if canOverrideWipe(&d.Run) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 templ.SafeURL
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 101, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"inline\"><button type=\"submit\" class=\"btn-danger\">Override wipe-probe</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if hasReport(&d.Run) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a class=\"button-like\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 templ.SafeURL
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Run.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 106, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" target=\"_blank\" rel=\"noopener\">View report</a> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if d.Run.State.IsTerminal() {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<form method=\"post\" action=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 templ.SafeURL
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 109, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"inline\"><button type=\"submit\" class=\"btn-primary\">Start new run</button></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></header>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// HoldBanner is the "Host is holding — SSH available" strip when a run
// is FailedHolding with an IP recorded. Emits an empty placeholder
// otherwise so the first SSE push when a hold actually fires has a
// target to swap into.
func HoldBanner(d RunPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var22 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if d.Run.State == model.StateFailedHolding && d.Run.HoldIP != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 124, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"hold-banner\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 126, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-swap=\"outerHTML\"><span class=\"hold-banner-label\">Host is holding — SSH available:</span> <code class=\"hold-ssh\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.HoldKeyPath, d.Run.HoldIP))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 130, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</code></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 134, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" class=\"detail-hold-placeholder\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 136, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-swap=\"outerHTML\"></section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
// RunSpecDiffs renders the "Spec diffs (N)" collapsible. The wrapper is
// always emitted (even when empty) so SpecValidate-time SSE pushes have
// a target; the <details> body only renders when diffs exist.
func RunSpecDiffs(d RunPageData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil {
templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<section id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 147, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"detail-section detail-diffs\" sse-swap=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 149, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(d.SpecDiffs) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<details")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasCriticalDiff(d.SpecDiffs) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " open")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "><summary><h2>Spec diffs (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 154, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ")</h2></summary><ul class=\"diff-list\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, diff := range d.SpecDiffs {
var templ_7745c5c3_Var32 = []any{"diff-row", "diff-" + diff.Severity}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var32).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><div class=\"diff-field\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 158, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><div class=\"diff-expected\">expected: <code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 159, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code></div><div class=\"diff-actual\">actual: <code>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 160, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</code></div></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</ul></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// RenderRunHeaderString, RenderHoldBannerString, and
// RenderRunSpecDiffsString render each region to a string for the
// orchestrator's SSE publish path. Matches the RenderTileString pattern.
func RenderRunHeaderString(d RunPageData) string {
var buf bytes.Buffer
_ = RunHeader(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderHoldBannerString(d RunPageData) string {
var buf bytes.Buffer
_ = HoldBanner(d).Render(context.Background(), &buf)
return buf.String()
}
func RenderRunSpecDiffsString(d RunPageData) string {
var buf bytes.Buffer
_ = RunSpecDiffs(d).Render(context.Background(), &buf)
return buf.String()
}
var _ = templruntime.GeneratedTemplate