Click a tile to open /hosts/{id} — the canonical control surface per
host. Timeline renders every pre-stage, stage, and terminal node in
order, with the current one pulsing, failed ones flagged, and
downstream ones dimmed as skipped. Detail page shows summary, hold
card (when holding), all action buttons, spec diffs, a full-height
log pane, and a collapsed expected-spec YAML.
Tile slims to name, last-seen, status, and one primary action; a
CSS-overlay <a> makes the whole card clickable while buttons stay
receptive via z-index.
Runner.publishTileUpdate now also emits pipeline-{runID} fragments,
and CompleteStage wraps Stages.CompleteByName so stage completions
advance the timeline live — without this the dots only moved on
state transitions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,7 @@ func main() {
|
|||||||
orchestrator.TileRenderer = func(ctx context.Context, host model.Host, latest *model.Run) string {
|
orchestrator.TileRenderer = func(ctx context.Context, host model.Host, latest *model.Run) string {
|
||||||
return templates.RenderTileString(tiles.Build(ctx, host, latest))
|
return templates.RenderTileString(tiles.Build(ctx, host, latest))
|
||||||
}
|
}
|
||||||
|
orchestrator.PipelineRenderer = templates.RenderPipelineString
|
||||||
|
|
||||||
notifyReg, err := notify.BuildRegistry(cfg.Notifiers, cfg.Routes)
|
notifyReg, err := notify.BuildRegistry(cfg.Notifiers, cfg.Routes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,6 +99,8 @@ func main() {
|
|||||||
ui := &api.UI{
|
ui := &api.UI{
|
||||||
Hosts: hostStore,
|
Hosts: hostStore,
|
||||||
Runs: runStore,
|
Runs: runStore,
|
||||||
|
Stages: stageStore,
|
||||||
|
SpecDiffs: specDiffStore,
|
||||||
Artifacts: artifactStore,
|
Artifacts: artifactStore,
|
||||||
EventHub: hub,
|
EventHub: hub,
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(body.Summary) > 0 {
|
if len(body.Summary) > 0 {
|
||||||
summaryJSON = string(body.Summary)
|
summaryJSON = string(body.Summary)
|
||||||
}
|
}
|
||||||
if err := a.Stages.CompleteByName(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
|
if err := a.Runner.CompleteStage(r.Context(), runID, body.Stage, stageState, summaryJSON); err != nil {
|
||||||
http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -544,7 +544,7 @@ func (a *Agent) resolveSpecValidate(r *http.Request, runID int64) {
|
|||||||
"critical": critical,
|
"critical": critical,
|
||||||
})
|
})
|
||||||
if critical > 0 {
|
if critical > 0 {
|
||||||
_ = a.Stages.CompleteByName(r.Context(), runID, "SpecValidate", model.StageFailed, string(summaryBuf))
|
_ = a.Runner.CompleteStage(r.Context(), runID, "SpecValidate", model.StageFailed, string(summaryBuf))
|
||||||
_ = a.Runs.SetFailedStage(r.Context(), runID, "SpecValidate")
|
_ = a.Runs.SetFailedStage(r.Context(), runID, "SpecValidate")
|
||||||
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
|
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
|
||||||
log.Printf("specvalidate: failed-transition: %v", err)
|
log.Printf("specvalidate: failed-transition: %v", err)
|
||||||
@@ -561,7 +561,7 @@ func (a *Agent) resolveSpecValidate(r *http.Request, runID int64) {
|
|||||||
URL: a.runLinkURL(runID),
|
URL: a.runLinkURL(runID),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
_ = a.Stages.CompleteByName(r.Context(), runID, "SpecValidate", model.StagePassed, string(summaryBuf))
|
_ = a.Runner.CompleteStage(r.Context(), runID, "SpecValidate", model.StagePassed, string(summaryBuf))
|
||||||
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageCompleted); err != nil {
|
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageCompleted); err != nil {
|
||||||
log.Printf("specvalidate: advance: %v", err)
|
log.Printf("specvalidate: advance: %v", err)
|
||||||
}
|
}
|
||||||
@@ -591,7 +591,7 @@ func (a *Agent) readInventoryArtifact(r *http.Request, runID int64) (*spec.Inven
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) failStage(r *http.Request, runID int64, stage, message string) {
|
func (a *Agent) failStage(r *http.Request, runID int64, stage, message string) {
|
||||||
_ = a.Stages.CompleteByName(r.Context(), runID, stage, model.StageFailed, fmt.Sprintf(`{"error":%q}`, message))
|
_ = a.Runner.CompleteStage(r.Context(), runID, stage, model.StageFailed, fmt.Sprintf(`{"error":%q}`, message))
|
||||||
_ = a.Runs.SetFailedStage(r.Context(), runID, stage)
|
_ = a.Runs.SetFailedStage(r.Context(), runID, stage)
|
||||||
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
|
if _, err := a.Runner.Transition(r.Context(), runID, orchestrator.TriggerStageFailed); err != nil {
|
||||||
log.Printf("failStage: transition run %d: %v", runID, err)
|
log.Printf("failStage: transition run %d: %v", runID, err)
|
||||||
@@ -889,7 +889,7 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) {
|
|||||||
"stages": len(stages),
|
"stages": len(stages),
|
||||||
"diffs": len(diffs),
|
"diffs": len(diffs),
|
||||||
})
|
})
|
||||||
if err := a.Stages.CompleteByName(ctx, runID, "Reporting", model.StagePassed, string(summaryBuf)); err != nil {
|
if err := a.Runner.CompleteStage(ctx, runID, "Reporting", model.StagePassed, string(summaryBuf)); err != nil {
|
||||||
log.Printf("reporting: complete stage: %v", err)
|
log.Printf("reporting: complete stage: %v", err)
|
||||||
}
|
}
|
||||||
if err := a.Runs.MarkCompleted(ctx, runID, path); err != nil {
|
if err := a.Runs.MarkCompleted(ctx, runID, path); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"vetting/internal/api"
|
||||||
|
"vetting/internal/db"
|
||||||
|
"vetting/internal/events"
|
||||||
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/orchestrator"
|
||||||
|
"vetting/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
hosts := &store.Hosts{DB: conn}
|
||||||
|
runs := &store.Runs{DB: conn}
|
||||||
|
stages := &store.Stages{DB: conn}
|
||||||
|
diffs := &store.SpecDiffs{DB: conn}
|
||||||
|
arts := &store.Artifacts{DB: conn}
|
||||||
|
hub := events.NewHub()
|
||||||
|
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
||||||
|
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
|
||||||
|
ui := &api.UI{
|
||||||
|
Hosts: hosts,
|
||||||
|
Runs: runs,
|
||||||
|
Stages: stages,
|
||||||
|
SpecDiffs: diffs,
|
||||||
|
Artifacts: arts,
|
||||||
|
EventHub: hub,
|
||||||
|
Runner: runner,
|
||||||
|
Tiles: tiles,
|
||||||
|
}
|
||||||
|
return ui, hosts, runs
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailReq(id int64) *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d", id), nil)
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("id", fmt.Sprintf("%d", id))
|
||||||
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostDetail_OK(t *testing.T) {
|
||||||
|
ui, hosts, runs := setupDetail(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := hosts.Create(ctx, model.Host{
|
||||||
|
Name: "detail-host",
|
||||||
|
MAC: "aa:bb:cc:dd:ee:30",
|
||||||
|
WoLBroadcastIP: "10.0.0.255",
|
||||||
|
WoLPort: 9,
|
||||||
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host: %v", err)
|
||||||
|
}
|
||||||
|
runID, err := runs.Create(ctx, id, "deadbeef")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReq(id))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "detail-host") {
|
||||||
|
t.Fatalf("body missing host name: %s", body)
|
||||||
|
}
|
||||||
|
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID)
|
||||||
|
if !strings.Contains(body, wantPipelineID) {
|
||||||
|
t.Fatalf("body missing %s", wantPipelineID)
|
||||||
|
}
|
||||||
|
wantLogID := fmt.Sprintf(`id="log-%d"`, runID)
|
||||||
|
if !strings.Contains(body, wantLogID) {
|
||||||
|
t.Fatalf("body missing %s", wantLogID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostDetail_NeverRun(t *testing.T) {
|
||||||
|
ui, hosts, _ := setupDetail(t)
|
||||||
|
id, err := hosts.Create(context.Background(), model.Host{
|
||||||
|
Name: "never-run",
|
||||||
|
MAC: "aa:bb:cc:dd:ee:31",
|
||||||
|
WoLBroadcastIP: "10.0.0.255",
|
||||||
|
WoLPort: 9,
|
||||||
|
ExpectedSpecYAML: "memory:\n total_gib: 8\n",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host: %v", err)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReq(id))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "Start vetting") {
|
||||||
|
t.Fatalf("never-run page missing Start vetting: %s", body)
|
||||||
|
}
|
||||||
|
// Ghost pipeline: all nodes rendered but none are running/failed/passed.
|
||||||
|
if strings.Contains(body, "stage-dot-running") || strings.Contains(body, "stage-dot-failed") {
|
||||||
|
t.Fatalf("ghost pipeline should have no running/failed dots: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "stage-dot-pending") {
|
||||||
|
t.Fatalf("expected pending stage dots in ghost pipeline: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostDetail_UnknownID(t *testing.T) {
|
||||||
|
ui, _, _ := setupDetail(t)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReq(9999))
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostDetail_BadID(t *testing.T) {
|
||||||
|
ui, _, _ := setupDetail(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/hosts/not-a-number", nil)
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("id", "not-a-number")
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, req)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ import (
|
|||||||
type UI struct {
|
type UI struct {
|
||||||
Hosts *store.Hosts
|
Hosts *store.Hosts
|
||||||
Runs *store.Runs
|
Runs *store.Runs
|
||||||
|
Stages *store.Stages
|
||||||
|
SpecDiffs *store.SpecDiffs
|
||||||
Artifacts *store.Artifacts
|
Artifacts *store.Artifacts
|
||||||
EventHub *events.Hub
|
EventHub *events.Hub
|
||||||
Runner *orchestrator.Runner
|
Runner *orchestrator.Runner
|
||||||
@@ -74,6 +76,51 @@ 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
|
||||||
|
// timeline, hold card, action row, spec diffs, log pane, meta. Same
|
||||||
|
// enrichment path as Dashboard for tile data; additionally reads stage
|
||||||
|
// rows + spec diffs for the latest run to populate the timeline and
|
||||||
|
// diff list.
|
||||||
|
func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host, err := u.Hosts.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latest, err := u.Runs.LatestForHost(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var stages []model.Stage
|
||||||
|
var diffs []model.SpecDiff
|
||||||
|
if latest != nil {
|
||||||
|
if u.Stages != nil {
|
||||||
|
stages, _ = u.Stages.ListForRun(r.Context(), latest.ID)
|
||||||
|
}
|
||||||
|
if u.SpecDiffs != nil {
|
||||||
|
diffs, _ = u.SpecDiffs.ListForRun(r.Context(), latest.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := u.Tiles.Build(r.Context(), *host, latest)
|
||||||
|
data := templates.HostDetailData{
|
||||||
|
Tile: t,
|
||||||
|
Stages: stages,
|
||||||
|
SpecDiffs: diffs,
|
||||||
|
}
|
||||||
|
_ = templates.HostDetail(data).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
// StartRun creates a new Run for the host, issues an agent token, and
|
// StartRun creates a new Run for the host, issues an agent token, and
|
||||||
// transitions Registered→Queued. The dispatcher goroutine picks it up
|
// transitions Registered→Queued. The dispatcher goroutine picks it up
|
||||||
// and fires WoL.
|
// and fires WoL.
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ 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.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}/override-wipe", d.UI.OverrideWipeStorage)
|
r.Post("/hosts/{id}/override-wipe", d.UI.OverrideWipeStorage)
|
||||||
|
|||||||
@@ -50,6 +50,21 @@ func (r *Runner) StartStage(ctx context.Context, runID int64, name string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CompleteStage marks a stage row passed/failed/skipped and publishes a
|
||||||
|
// tile + pipeline refresh. Wrapper around Stages.CompleteByName so every
|
||||||
|
// stage completion triggers an SSE update — without this, stage dots on
|
||||||
|
// the pipeline wouldn't advance until the next run-state transition.
|
||||||
|
func (r *Runner) CompleteStage(ctx context.Context, runID int64, name string, state model.StageState, summaryJSON string) error {
|
||||||
|
if err := r.Stages.CompleteByName(ctx, runID, name, state, summaryJSON); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
run, err := r.Runs.Get(ctx, runID)
|
||||||
|
if err == nil {
|
||||||
|
r.publishTileUpdate(ctx, run.HostID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// PublishTileUpdate is the exported entry point for non-orchestrator
|
// PublishTileUpdate is the exported entry point for non-orchestrator
|
||||||
// callers (the UI heartbeat handler) that change tile-visible state
|
// callers (the UI heartbeat handler) that change tile-visible state
|
||||||
// without going through Transition.
|
// without going through Transition.
|
||||||
@@ -70,6 +85,19 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
|
|||||||
}
|
}
|
||||||
payload := renderTileSSE(ctx, *host, latest)
|
payload := renderTileSSE(ctx, *host, latest)
|
||||||
r.EventHub.Publish(events.Event{Name: fmt.Sprintf("tile-%d", hostID), Payload: payload})
|
r.EventHub.Publish(events.Event{Name: fmt.Sprintf("tile-%d", hostID), Payload: payload})
|
||||||
|
|
||||||
|
// Pipeline fragment — same call sites as the tile refresh, keyed by
|
||||||
|
// run ID so the detail page's <section sse-swap="pipeline-N"> picks
|
||||||
|
// it up. Silently skips when no renderer is wired or no run exists.
|
||||||
|
if latest != nil && PipelineRenderer != nil && r.Stages != nil {
|
||||||
|
stages, err := r.Stages.ListForRun(ctx, latest.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("publishTileUpdate: list stages run %d: %v", latest.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pipePayload := PipelineRenderer(latest, stages)
|
||||||
|
r.EventHub.Publish(events.Event{Name: fmt.Sprintf("pipeline-%d", latest.ID), Payload: pipePayload})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TileRenderer renders a single tile fragment. Registered at startup
|
// TileRenderer renders a single tile fragment. Registered at startup
|
||||||
@@ -79,6 +107,11 @@ func (r *Runner) publishTileUpdate(ctx context.Context, hostID int64) {
|
|||||||
// template package.
|
// template package.
|
||||||
var TileRenderer func(ctx context.Context, host model.Host, latest *model.Run) string
|
var TileRenderer func(ctx context.Context, host model.Host, latest *model.Run) string
|
||||||
|
|
||||||
|
// PipelineRenderer renders the detail-page pipeline fragment for the
|
||||||
|
// given run + its stage rows. Registered alongside TileRenderer so
|
||||||
|
// orchestrator stays free of template imports.
|
||||||
|
var PipelineRenderer func(run *model.Run, stages []model.Stage) string
|
||||||
|
|
||||||
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 {
|
||||||
return fmt.Sprintf(`<article id="host-%d">state change</article>`, host.ID)
|
return fmt.Sprintf(`<article id="host-%d">state change</article>`, host.ID)
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package orchestrator_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vetting/internal/db"
|
||||||
|
"vetting/internal/events"
|
||||||
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/orchestrator"
|
||||||
|
"vetting/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupRunner wires a real DB + stores + hub and registers a minimal
|
||||||
|
// TileRenderer/PipelineRenderer so publishTileUpdate emits something
|
||||||
|
// recognisable. Returns Runner plus helpers to drain events.
|
||||||
|
func setupRunner(t *testing.T) (*orchestrator.Runner, *store.Hosts, *store.Runs, *events.Hub, func()) {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
hub := events.NewHub()
|
||||||
|
hosts := &store.Hosts{DB: conn}
|
||||||
|
runs := &store.Runs{DB: conn}
|
||||||
|
stages := &store.Stages{DB: conn}
|
||||||
|
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
||||||
|
|
||||||
|
// Deterministic renderer stubs — use known substrings so tests can
|
||||||
|
// grep the published fragments without parsing HTML.
|
||||||
|
prevTile := orchestrator.TileRenderer
|
||||||
|
prevPipe := orchestrator.PipelineRenderer
|
||||||
|
orchestrator.TileRenderer = func(_ context.Context, host model.Host, _ *model.Run) string {
|
||||||
|
return fmt.Sprintf(`<article id="host-%d">tile</article>`, host.ID)
|
||||||
|
}
|
||||||
|
orchestrator.PipelineRenderer = func(run *model.Run, _ []model.Stage) string {
|
||||||
|
return fmt.Sprintf(`<section id="pipeline-%d">pipeline</section>`, run.ID)
|
||||||
|
}
|
||||||
|
cleanup := func() {
|
||||||
|
orchestrator.TileRenderer = prevTile
|
||||||
|
orchestrator.PipelineRenderer = prevPipe
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
return runner, hosts, runs, hub, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPublishesTileAndPipelineOnTransition asserts that a single
|
||||||
|
// Transition call publishes both the tile-{hostID} and pipeline-{runID}
|
||||||
|
// fragments — the detail-page timeline needs this to advance on every
|
||||||
|
// state change without its own call site.
|
||||||
|
func TestPublishesTileAndPipelineOnTransition(t *testing.T) {
|
||||||
|
runner, hosts, runs, hub, cleanup := setupRunner(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
hostID, err := hosts.Create(ctx, model.Host{
|
||||||
|
Name: "runner-tile",
|
||||||
|
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, hostID, "deadbeef")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, cancel := hub.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
// Subscribe one more time so we have a channel we can drain.
|
||||||
|
_, ch, cancel2 := hub.Subscribe()
|
||||||
|
defer cancel2()
|
||||||
|
|
||||||
|
// Queued → WaitingWoL on Dispatched.
|
||||||
|
if _, err := runner.Transition(ctx, runID, orchestrator.TriggerDispatched); err != nil {
|
||||||
|
t.Fatalf("transition: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect events with a short deadline; we expect tile + pipeline
|
||||||
|
// from this one Transition call.
|
||||||
|
wantTile := fmt.Sprintf("tile-%d", hostID)
|
||||||
|
wantPipeline := fmt.Sprintf("pipeline-%d", runID)
|
||||||
|
sawTile, sawPipeline := false, false
|
||||||
|
deadline := time.After(500 * time.Millisecond)
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev := <-ch:
|
||||||
|
if ev.Name == wantTile {
|
||||||
|
sawTile = true
|
||||||
|
}
|
||||||
|
if ev.Name == wantPipeline {
|
||||||
|
sawPipeline = true
|
||||||
|
}
|
||||||
|
if sawTile && sawPipeline {
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawTile {
|
||||||
|
t.Errorf("no %s event published", wantTile)
|
||||||
|
}
|
||||||
|
if !sawPipeline {
|
||||||
|
t.Errorf("no %s event published", wantPipeline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompleteStagePublishesPipeline covers the stage-completion path
|
||||||
|
// that used to go direct-to-Stages, bypassing the SSE refresh. The
|
||||||
|
// Runner.CompleteStage wrapper exists so stage-dot advancements show up
|
||||||
|
// on the detail page without waiting for the next run-state transition.
|
||||||
|
func TestCompleteStagePublishesPipeline(t *testing.T) {
|
||||||
|
runner, hosts, runs, hub, cleanup := setupRunner(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
hostID, err := hosts.Create(ctx, model.Host{
|
||||||
|
Name: "runner-cs",
|
||||||
|
MAC: "aa:bb:cc:dd:ee:41",
|
||||||
|
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, hostID, "deadbeef")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create run: %v", err)
|
||||||
|
}
|
||||||
|
// CompleteStage needs stage rows to exist first — Seed them.
|
||||||
|
stages := &store.Stages{DB: runs.DB}
|
||||||
|
if err := stages.Seed(ctx, runID); err != nil {
|
||||||
|
t.Fatalf("seed stages: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ch, cancel := hub.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := runner.CompleteStage(ctx, runID, "Inventory", model.StagePassed, `{}`); err != nil {
|
||||||
|
t.Fatalf("CompleteStage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPipeline := fmt.Sprintf("pipeline-%d", runID)
|
||||||
|
deadline := time.After(500 * time.Millisecond)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev := <-ch:
|
||||||
|
if ev.Name == wantPipeline {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("no %s event published by CompleteStage", wantPipeline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,22 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
position: relative;
|
||||||
|
transition: border-color .12s ease, transform .12s ease;
|
||||||
}
|
}
|
||||||
|
.tile:hover { border-color: var(--accent); }
|
||||||
|
.tile-link {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tile > *:not(.tile-link) { position: relative; z-index: 1; }
|
||||||
|
.tile-primary-action { display: flex; gap: 8px; }
|
||||||
|
.tile-primary-action .inline { margin: 0; }
|
||||||
|
.tile-primary-action:empty { display: none; }
|
||||||
.tile-head { display: flex; justify-content: space-between; align-items: center; }
|
.tile-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.tile-name { font-weight: 600; }
|
.tile-name { font-weight: 600; }
|
||||||
.tile-header-right { display: flex; align-items: center; gap: 10px; }
|
.tile-header-right { display: flex; align-items: center; gap: 10px; }
|
||||||
@@ -264,3 +279,184 @@ body.bare main { max-width: none; }
|
|||||||
}
|
}
|
||||||
.manual-register summary:hover { color: var(--text); }
|
.manual-register summary:hover { color: var(--text); }
|
||||||
.manual-register[open] summary { margin-bottom: 12px; }
|
.manual-register[open] summary { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
/* ===== Host detail page ===== */
|
||||||
|
.detail { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.breadcrumb { color: var(--text-dim); font-size: 13px; display: flex; gap: 6px; }
|
||||||
|
.breadcrumb a { color: var(--text-dim); }
|
||||||
|
.breadcrumb a:hover { color: var(--text); }
|
||||||
|
.breadcrumb-sep { opacity: .5; }
|
||||||
|
|
||||||
|
.detail-summary {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.detail-summary.tile-fail { border-color: rgba(229,100,102,.6); }
|
||||||
|
.detail-summary.tile-pass { border-color: rgba(53,194,123,.5); }
|
||||||
|
.detail-summary.tile-active { border-color: var(--accent); }
|
||||||
|
.detail-summary-head { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.detail-name { margin: 0; font-size: 22px; }
|
||||||
|
.detail-status-row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.detail-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 8px 24px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.detail-meta div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
||||||
|
.detail-meta dt { color: var(--text-dim); }
|
||||||
|
.detail-meta dd { margin: 0; font-family: var(--mono); }
|
||||||
|
.detail-meta dd.bad { color: var(--danger); }
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.detail-section h2 { margin: 0 0 12px; font-size: 15px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; font-weight: 600; }
|
||||||
|
.detail-section details > summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.detail-section details > summary::before { content: "▸"; color: var(--text-dim); font-size: 12px; transition: transform .1s ease; }
|
||||||
|
.detail-section details[open] > summary::before { transform: rotate(90deg); }
|
||||||
|
.detail-section details > summary h2 { margin: 0; }
|
||||||
|
|
||||||
|
.detail-hold {
|
||||||
|
background: rgba(229,100,102,.08);
|
||||||
|
border-color: rgba(229,100,102,.35);
|
||||||
|
}
|
||||||
|
.detail-hold h2 { color: var(--danger); }
|
||||||
|
.hold-ssh {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
display: block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #0b0d12;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions-row { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.detail-actions-row .inline { margin: 0; }
|
||||||
|
|
||||||
|
.detail-log {
|
||||||
|
background: #0b0d12;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.detail-log:empty::before { content: "(no log output yet)"; color: var(--text-dim); opacity: .5; }
|
||||||
|
.detail-log .log-line { white-space: pre-wrap; }
|
||||||
|
.detail-log .log-warn { color: var(--warn); }
|
||||||
|
.detail-log .log-error { color: var(--danger); }
|
||||||
|
|
||||||
|
.diff-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.diff-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.diff-row code { font-family: var(--mono); font-size: 12px; color: var(--text); }
|
||||||
|
.diff-field { font-weight: 600; }
|
||||||
|
.diff-expected, .diff-actual { color: var(--text-dim); }
|
||||||
|
.diff-critical { border-color: rgba(229,100,102,.5); background: rgba(229,100,102,.06); }
|
||||||
|
.diff-critical .diff-field { color: var(--danger); }
|
||||||
|
.diff-warning { border-color: rgba(228,169,75,.45); background: rgba(228,169,75,.05); }
|
||||||
|
.diff-warning .diff-field { color: var(--warn); }
|
||||||
|
.diff-info { opacity: .75; }
|
||||||
|
|
||||||
|
.detail-host-meta h3 { margin: 12px 0 6px; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.detail-notes p { margin: 0; color: var(--text); }
|
||||||
|
.detail-spec-yaml {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #0b0d12;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 12px;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Pipeline timeline ===== */
|
||||||
|
.pipeline {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 12px 4px 6px;
|
||||||
|
}
|
||||||
|
.stage-node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 82px;
|
||||||
|
padding: 0 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.stage-dot {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.stage-dot-passed { background: var(--success); border-color: var(--success); color: #0b0d12; }
|
||||||
|
.stage-dot-running { background: var(--accent-strong); border-color: var(--accent); color: #fff; animation: pulse 1.2s ease-in-out infinite; }
|
||||||
|
.stage-dot-failed { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
|
.stage-dot-skipped { background: transparent; border-color: var(--border); color: var(--text-dim); opacity: .45; }
|
||||||
|
.stage-dot-pending { background: transparent; border-color: var(--border); color: transparent; }
|
||||||
|
|
||||||
|
.stage-name { font-size: 11px; color: var(--text-dim); text-align: center; }
|
||||||
|
.stage-node-passed .stage-name { color: var(--text); }
|
||||||
|
.stage-node-running .stage-name { color: var(--accent); }
|
||||||
|
.stage-node-failed .stage-name { color: var(--danger); }
|
||||||
|
.stage-node-skipped .stage-name { opacity: .5; }
|
||||||
|
.stage-duration { font-size: 10px; color: var(--text-dim); font-family: var(--mono); min-height: 12px; }
|
||||||
|
|
||||||
|
.stage-connector {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12px;
|
||||||
|
height: 2px;
|
||||||
|
align-self: center;
|
||||||
|
margin-top: -18px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.stage-connector-passed { background: var(--success); }
|
||||||
|
.stage-connector-running { background: linear-gradient(90deg, var(--success), var(--accent)); }
|
||||||
|
.stage-connector-failed { background: var(--danger); }
|
||||||
|
.stage-connector-skipped { background: var(--border); opacity: .5; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(60,130,246,0); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HostDetailData is the full payload the detail handler hands to the
|
||||||
|
// HostDetail template. Tile carries host + latest-run enrichment (same
|
||||||
|
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
||||||
|
// and diff list.
|
||||||
|
type HostDetailData struct {
|
||||||
|
Tile TileData
|
||||||
|
Stages []model.Stage
|
||||||
|
SpecDiffs []model.SpecDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
templ HostDetail(d HostDetailData) {
|
||||||
|
@Layout(d.Tile.Host.Name) {
|
||||||
|
<section class="detail" 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>
|
||||||
|
|
||||||
|
<header class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }>
|
||||||
|
<div class="detail-summary-head">
|
||||||
|
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
||||||
|
<div class="detail-status-row">
|
||||||
|
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
|
||||||
|
<span class="tile-status">{ tileStatus(d.Tile.Latest) }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
|
||||||
|
<div>
|
||||||
|
<dt>Failed at</dt>
|
||||||
|
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if d.Tile.SpecDiffCritical > 0 {
|
||||||
|
<div>
|
||||||
|
<dt>Spec diffs</dt>
|
||||||
|
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
if d.Tile.Latest != nil {
|
||||||
|
<section
|
||||||
|
id={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
||||||
|
class="detail-section"
|
||||||
|
sse-swap={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
@Pipeline(BuildPipeline(d.Tile.Latest, d.Stages))
|
||||||
|
</section>
|
||||||
|
} else {
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
@Pipeline(BuildPipeline(nil, nil))
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Tile.Latest != nil && d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
||||||
|
<section class="detail-section detail-hold">
|
||||||
|
<h2>Host is holding — SSH available</h2>
|
||||||
|
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="detail-section detail-actions">
|
||||||
|
<h2>Actions</h2>
|
||||||
|
<div class="detail-actions-row">
|
||||||
|
if canStart(d.Tile.Latest) {
|
||||||
|
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline">
|
||||||
|
<button type="submit">Start vetting</button>
|
||||||
|
</form>
|
||||||
|
} else {
|
||||||
|
<button type="button" disabled>Run in flight</button>
|
||||||
|
}
|
||||||
|
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="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">
|
||||||
|
<button type="submit" class="danger">Delete host</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
if len(d.SpecDiffs) > 0 {
|
||||||
|
<section class="detail-section detail-diffs">
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Tile.Latest != nil {
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2>Log</h2>
|
||||||
|
<div
|
||||||
|
class="detail-log"
|
||||||
|
id={ fmt.Sprintf("log-%d", d.Tile.Latest.ID) }
|
||||||
|
sse-swap={ fmt.Sprintf("log-%d", d.Tile.Latest.ID) }
|
||||||
|
hx-swap="beforeend"
|
||||||
|
></div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="detail-section detail-host-meta">
|
||||||
|
<details>
|
||||||
|
<summary><h2>Host details</h2></summary>
|
||||||
|
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>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
// 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 (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HostDetailData is the full payload the detail handler hands to the
|
||||||
|
// HostDetail template. Tile carries host + latest-run enrichment (same
|
||||||
|
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
||||||
|
// and diff list.
|
||||||
|
type HostDetailData struct {
|
||||||
|
Tile TileData
|
||||||
|
Stages []model.Stage
|
||||||
|
SpecDiffs []model.SpecDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
func HostDetail(d HostDetailData) 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=\"detail\" 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.Tile.Host.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 25, Col: 28}
|
||||||
|
}
|
||||||
|
_, 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
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 = []any{"detail-summary", "tile-" + tileMood(d.Tile.Latest)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<header class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, "\"><div class=\"detail-summary-head\"><h1 class=\"detail-name\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 30, Col: 47}
|
||||||
|
}
|
||||||
|
_, 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, 5, "</h1><div class=\"detail-status-row\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 = []any{"tile-last-seen", lastSeenClass(d.Tile.LastSeenAt)}
|
||||||
|
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, 6, "<span class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, 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/host_detail.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, 7, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 32, Col: 107}
|
||||||
|
}
|
||||||
|
_, 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, 8, "</span> <span class=\"tile-status\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 33, Col: 59}
|
||||||
|
}
|
||||||
|
_, 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, 9, "</span></div></div><dl class=\"detail-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.Tile.Host.MAC)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 39, Col: 27}
|
||||||
|
}
|
||||||
|
_, 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, 10, "</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.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 43, Col: 81}
|
||||||
|
}
|
||||||
|
_, 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, 11, "</dd></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div><dt>Failed at</dt><dd class=\"bad\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 48, Col: 50}
|
||||||
|
}
|
||||||
|
_, 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, 13, "</dd></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Tile.SpecDiffCritical > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div><dt>Spec diffs</dt><dd class=\"bad\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 54, Col: 76}
|
||||||
|
}
|
||||||
|
_, 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, "</dd></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</dl></header>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if d.Tile.Latest != nil {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 62, Col: 54}
|
||||||
|
}
|
||||||
|
_, 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, 18, "\" class=\"detail-section\" sse-swap=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 64, Col: 60}
|
||||||
|
}
|
||||||
|
_, 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, 19, "\" hx-swap=\"outerHTML\"><h2>Pipeline</h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Pipeline(BuildPipeline(d.Tile.Latest, d.Stages)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<section class=\"detail-section\"><h2>Pipeline</h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Pipeline(BuildPipeline(nil, nil)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Tile.Latest != nil && d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<section class=\"detail-section detail-hold\"><h2>Host is holding — SSH available</h2><code class=\"hold-ssh\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 80, 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, 24, "</code></section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<section class=\"detail-section detail-actions\"><h2>Actions</h2><div class=\"detail-actions-row\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if canStart(d.Tile.Latest) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<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.Tile.Host.ID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 88, Col: 96}
|
||||||
|
}
|
||||||
|
_, 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, 27, "\" class=\"inline\"><button type=\"submit\">Start vetting</button></form>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<button type=\"button\" disabled>Run in flight</button> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if canOverrideWipe(d.Tile.Latest) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<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.Tile.Host.ID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 95, Col: 104}
|
||||||
|
}
|
||||||
|
_, 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, 30, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Override wipe-probe</button></form>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasReport(d.Tile.Latest) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<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.Tile.Latest.ID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 100, Col: 95}
|
||||||
|
}
|
||||||
|
_, 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, 32, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<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/delete", d.Tile.Host.ID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 102, Col: 96}
|
||||||
|
}
|
||||||
|
_, 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, 34, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Delete host</button></form></div></section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(d.SpecDiffs) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<section class=\"detail-section detail-diffs\"><details")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if hasCriticalDiff(d.SpecDiffs) {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " open")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "><summary><h2>Spec diffs (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, 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/host_detail.templ`, Line: 111, Col: 68}
|
||||||
|
}
|
||||||
|
_, 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, 38, ")</h2></summary><ul class=\"diff-list\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, diff := range d.SpecDiffs {
|
||||||
|
var templ_7745c5c3_Var23 = []any{"diff-row", "diff-" + diff.Severity}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<li class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var23).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, 40, "\"><div class=\"diff-field\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 string
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 115, Col: 45}
|
||||||
|
}
|
||||||
|
_, 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, 41, "</div><div class=\"diff-expected\">expected: <code>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 116, Col: 67}
|
||||||
|
}
|
||||||
|
_, 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, 42, "</code></div><div class=\"diff-actual\">actual: <code>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var27 string
|
||||||
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 117, Col: 61}
|
||||||
|
}
|
||||||
|
_, 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, 43, "</code></div></li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</ul></details></section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Tile.Latest != nil {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<section class=\"detail-section\"><h2>Log</h2><div class=\"detail-log\" id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", d.Tile.Latest.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 130, Col: 50}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" sse-swap=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var29 string
|
||||||
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", d.Tile.Latest.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 131, Col: 56}
|
||||||
|
}
|
||||||
|
_, 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, 47, "\" hx-swap=\"beforeend\"></div></section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<section class=\"detail-section detail-host-meta\"><details><summary><h2>Host details</h2></summary> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if d.Tile.Host.Notes != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"detail-notes\"><h3>Notes</h3><p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var30 string
|
||||||
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 143, Col: 29}
|
||||||
|
}
|
||||||
|
_, 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, 50, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"detail-spec\"><h3>Expected spec</h3><pre class=\"detail-spec-yaml\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var31 string
|
||||||
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 148, 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, 52, "</pre></div></details></section></section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = Layout(d.Tile.Host.Name).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -8,9 +8,10 @@ import (
|
|||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostTile renders a single dashboard card. It's the SSE-swap target
|
// HostTile renders a single dashboard card. The whole tile is a link
|
||||||
// for per-host tile refreshes (`tile-N`) and contains a per-run log
|
// to /hosts/{id} (via a CSS-overlay <a>) — every control beyond the one
|
||||||
// pane (`log-M`) whose live tail is appended by the events hub.
|
// primary action lives on the detail page. It's the SSE-swap target
|
||||||
|
// for per-host tile refreshes (`tile-N`).
|
||||||
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) }
|
||||||
@@ -18,6 +19,7 @@ templ HostTile(t TileData) {
|
|||||||
sse-swap={ fmt.Sprintf("tile-%d", t.Host.ID) }
|
sse-swap={ fmt.Sprintf("tile-%d", t.Host.ID) }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
|
<a class="tile-link" href={ templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)) } aria-label={ "Open " + t.Host.Name }></a>
|
||||||
<header class="tile-head">
|
<header class="tile-head">
|
||||||
<div class="tile-name">{ t.Host.Name }</div>
|
<div class="tile-name">{ t.Host.Name }</div>
|
||||||
<div class="tile-header-right">
|
<div class="tile-header-right">
|
||||||
@@ -25,61 +27,14 @@ templ HostTile(t TileData) {
|
|||||||
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<dl class="tile-meta">
|
<div class="tile-primary-action">
|
||||||
<div>
|
|
||||||
<dt>MAC</dt>
|
|
||||||
<dd>{ t.Host.MAC }</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>WoL</dt>
|
|
||||||
<dd>{ fmt.Sprintf("%s:%d", t.Host.WoLBroadcastIP, t.Host.WoLPort) }</dd>
|
|
||||||
</div>
|
|
||||||
if t.Latest != nil && t.Latest.FailedStage != "" {
|
|
||||||
<div>
|
|
||||||
<dt>Failed at</dt>
|
|
||||||
<dd>{ t.Latest.FailedStage }</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if t.SpecDiffCritical > 0 {
|
|
||||||
<div>
|
|
||||||
<dt>Spec diffs</dt>
|
|
||||||
<dd class="bad">{ fmt.Sprintf("%d critical", t.SpecDiffCritical) }</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
if t.Latest != nil && t.Latest.State == model.StateFailedHolding && t.Latest.HoldIP != "" {
|
|
||||||
<div class="tile-hold">
|
|
||||||
<div class="hold-title">Host is holding — SSH available</div>
|
|
||||||
<code class="hold-ssh">{ sshInvocation(t.HoldKeyPath, t.Latest.HoldIP) }</code>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
if t.Latest != nil {
|
|
||||||
<div
|
|
||||||
class="tile-log"
|
|
||||||
id={ fmt.Sprintf("log-%d", t.Latest.ID) }
|
|
||||||
sse-swap={ fmt.Sprintf("log-%d", t.Latest.ID) }
|
|
||||||
hx-swap="beforeend"
|
|
||||||
></div>
|
|
||||||
}
|
|
||||||
<div class="tile-actions">
|
|
||||||
if canStart(t.Latest) {
|
if canStart(t.Latest) {
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline">
|
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline">
|
||||||
<button type="submit">Start vetting</button>
|
<button type="submit">Start vetting</button>
|
||||||
</form>
|
</form>
|
||||||
} else {
|
} else if hasReport(t.Latest) {
|
||||||
<button type="button" disabled>Run in flight</button>
|
|
||||||
}
|
|
||||||
if canOverrideWipe(t.Latest) {
|
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", t.Host.ID)) } class="inline">
|
|
||||||
<button type="submit" class="danger">Override wipe-probe</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
if hasReport(t.Latest) {
|
|
||||||
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
||||||
}
|
}
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", t.Host.ID)) } class="inline">
|
|
||||||
<button type="submit" class="danger">Delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import (
|
|||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostTile renders a single dashboard card. It's the SSE-swap target
|
// HostTile renders a single dashboard card. The whole tile is a link
|
||||||
// for per-host tile refreshes (`tile-N`) and contains a per-run log
|
// to /hosts/{id} (via a CSS-overlay <a>) — every control beyond the one
|
||||||
// pane (`log-M`) whose live tail is appended by the events hub.
|
// primary action lives on the detail page. It's the SSE-swap target
|
||||||
|
// for per-host tile refreshes (`tile-N`).
|
||||||
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
|
||||||
@@ -52,7 +53,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: `host_tile.templ`, Line: 16, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 17, 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 {
|
||||||
@@ -65,7 +66,7 @@ func HostTile(t TileData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 1, Col: 0}
|
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_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -78,276 +79,141 @@ 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: `host_tile.templ`, Line: 18, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap=\"outerHTML\"><header class=\"tile-head\"><div class=\"tile-name\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap=\"outerHTML\"><a class=\"tile-link\" href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 templ.SafeURL
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
|
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: `host_tile.templ`, Line: 22, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div><div class=\"tile-header-right\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" aria-label=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 = []any{"tile-last-seen", lastSeenClass(t.LastSeenAt)}
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 117}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
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, 6, "<span class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></a><header class=\"tile-head\"><div class=\"tile-name\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
|
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: `host_tile.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"tile-header-right\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 = []any{"tile-last-seen", lastSeenClass(t.LastSeenAt)}
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 24, Col: 95}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
|
||||||
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, 8, "</span><div class=\"tile-status\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 25, Col: 51}
|
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_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
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, 9, "</div></div></header><dl class=\"tile-meta\"><div><dt>MAC</dt><dd>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.MAC)
|
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: `host_tile.templ`, Line: 31, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</dd></div><div><dt>WoL</dt><dd>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</span><div class=\"tile-status\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", t.Host.WoLBroadcastIP, t.Host.WoLPort))
|
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: `host_tile.templ`, Line: 35, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, 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, "</dd></div>")
|
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 && t.Latest.FailedStage != "" {
|
if canStart(t.Latest) {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div><dt>Failed at</dt><dd>")
|
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(t.Latest.FailedStage)
|
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: `host_tile.templ`, Line: 40, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, 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, "</dd></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline\"><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
|
||||||
}
|
}
|
||||||
}
|
} else if hasReport(t.Latest) {
|
||||||
if t.SpecDiffCritical > 0 {
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a class=\"button-like\" href=\"")
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div><dt>Spec diffs</dt><dd class=\"bad\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 templ.SafeURL
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", t.SpecDiffCritical))
|
templ_7745c5c3_Var14, 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: `host_tile.templ`, Line: 46, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 36, Col: 88}
|
||||||
}
|
}
|
||||||
_, 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, 15, "</dd></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" 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, 16, "</dl>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></article>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if t.Latest != nil && t.Latest.State == model.StateFailedHolding && t.Latest.HoldIP != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"tile-hold\"><div class=\"hold-title\">Host is holding — SSH available</div><code class=\"hold-ssh\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 string
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(t.HoldKeyPath, t.Latest.HoldIP))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 53, Col: 74}
|
|
||||||
}
|
|
||||||
_, 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, 18, "</code></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if t.Latest != nil {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"tile-log\" 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("log-%d", t.Latest.ID))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 59, Col: 43}
|
|
||||||
}
|
|
||||||
_, 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, 20, "\" 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("log-%d", t.Latest.ID))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 60, Col: 49}
|
|
||||||
}
|
|
||||||
_, 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, 21, "\" hx-swap=\"beforeend\"></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div class=\"tile-actions\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if canStart(t.Latest) {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<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", t.Host.ID)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 66, 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, 24, "\" class=\"inline\"><button type=\"submit\">Start vetting</button></form>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<button type=\"button\" disabled>Run in flight</button> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if canOverrideWipe(t.Latest) {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<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", t.Host.ID)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 73, 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, 27, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Override wipe-probe</button></form>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasReport(t.Latest) {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<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", t.Latest.ID)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 78, Col: 88}
|
|
||||||
}
|
|
||||||
_, 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, 29, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<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/delete", t.Host.ID)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 80, 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, 31, "\" class=\"inline\"><button type=\"submit\" class=\"danger\">Delete</button></form></div></article>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHumanAgoFrom(t *testing.T) {
|
func TestHumanAgoFrom(t *testing.T) {
|
||||||
@@ -35,6 +39,37 @@ func TestHumanAgoFrom(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHostTile_OverlayLink asserts the tile includes the tile-link <a>
|
||||||
|
// that makes the whole card clickable. The action button stays a
|
||||||
|
// sibling element, so CSS (z-index) keeps it on top of the overlay.
|
||||||
|
func TestHostTile_OverlayLink(t *testing.T) {
|
||||||
|
data := TileData{
|
||||||
|
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
html := buf.String()
|
||||||
|
if !strings.Contains(html, `href="/hosts/42"`) {
|
||||||
|
t.Fatalf("tile missing overlay href: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `class="tile-link"`) {
|
||||||
|
t.Fatalf("tile missing tile-link class: %s", html)
|
||||||
|
}
|
||||||
|
// canStart(nil) is true → Start form must be present.
|
||||||
|
if !strings.Contains(html, `/hosts/42/start`) {
|
||||||
|
t.Fatalf("expected Start vetting form in tile: %s", html)
|
||||||
|
}
|
||||||
|
// Dropped content that used to live on the tile — confirm it has
|
||||||
|
// actually moved off so the slim-down is real.
|
||||||
|
for _, dropped := range []string{`tile-meta`, `tile-log`, `tile-actions`, `tile-hold`} {
|
||||||
|
if strings.Contains(html, dropped) {
|
||||||
|
t.Errorf("slim tile still contains dropped class %q", dropped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLastSeenLabelAndClass(t *testing.T) {
|
func TestLastSeenLabelAndClass(t *testing.T) {
|
||||||
if got := lastSeenLabel(nil); got != "never" {
|
if got := lastSeenLabel(nil); got != "never" {
|
||||||
t.Fatalf("label nil = %q, want never", got)
|
t.Fatalf("label nil = %q, want never", got)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func Layout(title string) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 9, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 9, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 38, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 38, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PipelineNode is one dot on the detail-page timeline. The template
|
||||||
|
// doesn't know stages from pre-stages — it just renders whatever the
|
||||||
|
// BuildPipeline helper produces, in order.
|
||||||
|
type PipelineNode struct {
|
||||||
|
Name string
|
||||||
|
State string // pending|running|passed|failed|skipped
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// preStageOrder are the nodes that show before the first real stage.
|
||||||
|
// Derived from run.State rather than stage rows since we don't persist
|
||||||
|
// pre-stage timestamps.
|
||||||
|
var preStageOrder = []model.RunState{
|
||||||
|
model.StateQueued,
|
||||||
|
model.StateWaitingWoL,
|
||||||
|
model.StateBooting,
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStateRank returns how far along the state machine a run is, using
|
||||||
|
// a linear ranking across pre-stages, stage states, and terminals. Used
|
||||||
|
// by BuildPipeline to decide which pre-stage nodes are "past" (passed),
|
||||||
|
// "current" (running), or "pending".
|
||||||
|
func runStateRank(s model.RunState) int {
|
||||||
|
order := []model.RunState{
|
||||||
|
model.StateRegistered,
|
||||||
|
model.StateQueued,
|
||||||
|
model.StateWaitingWoL,
|
||||||
|
model.StateBooting,
|
||||||
|
model.StateInventoryCheck,
|
||||||
|
model.StateSpecValidate,
|
||||||
|
model.StateSMART,
|
||||||
|
model.StateCPUStress,
|
||||||
|
model.StateStorage,
|
||||||
|
model.StateNetwork,
|
||||||
|
model.StateGPU,
|
||||||
|
model.StatePSU,
|
||||||
|
model.StateReporting,
|
||||||
|
model.StateCompleted,
|
||||||
|
}
|
||||||
|
for i, v := range order {
|
||||||
|
if v == s {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPipeline projects (run, stages) into a linear slice of nodes
|
||||||
|
// covering the whole lifecycle: pre-stage → stage rows → Completed.
|
||||||
|
//
|
||||||
|
// When run == nil we emit a ghost timeline (everything pending) so a
|
||||||
|
// never-run host still shows what's coming.
|
||||||
|
func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
||||||
|
nodes := make([]PipelineNode, 0, len(preStageOrder)+len(stages)+1)
|
||||||
|
|
||||||
|
// --- pre-stage nodes ---
|
||||||
|
for _, ps := range preStageOrder {
|
||||||
|
n := PipelineNode{Name: string(ps), State: "pending"}
|
||||||
|
if run != nil {
|
||||||
|
switch {
|
||||||
|
case run.State == model.StateFailedHolding || run.State == model.StateFailed:
|
||||||
|
// If we failed before reaching a stage, a pre-stage may
|
||||||
|
// still have been entered — keep the "past" rank logic.
|
||||||
|
if runStateRank(ps) < runStateRank(firstStageState(run)) {
|
||||||
|
n.State = "passed"
|
||||||
|
}
|
||||||
|
case run.State == ps:
|
||||||
|
n.State = "running"
|
||||||
|
case runStateRank(run.State) > runStateRank(ps):
|
||||||
|
n.State = "passed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- stage nodes (from stage rows) ---
|
||||||
|
failedBefore := false
|
||||||
|
for _, st := range stages {
|
||||||
|
n := PipelineNode{
|
||||||
|
Name: st.Name,
|
||||||
|
StartedAt: st.StartedAt,
|
||||||
|
CompletedAt: st.CompletedAt,
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case failedBefore:
|
||||||
|
n.State = "skipped"
|
||||||
|
case st.State == model.StagePassed:
|
||||||
|
n.State = "passed"
|
||||||
|
case st.State == model.StageRunning:
|
||||||
|
n.State = "running"
|
||||||
|
case st.State == model.StageFailed:
|
||||||
|
n.State = "failed"
|
||||||
|
failedBefore = true
|
||||||
|
case st.State == model.StageSkipped:
|
||||||
|
n.State = "skipped"
|
||||||
|
default:
|
||||||
|
n.State = "pending"
|
||||||
|
}
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- terminal Completed node ---
|
||||||
|
term := PipelineNode{Name: "Completed", State: "pending"}
|
||||||
|
if run != nil && run.State == model.StateCompleted {
|
||||||
|
term.State = "passed"
|
||||||
|
term.CompletedAt = run.CompletedAt
|
||||||
|
}
|
||||||
|
nodes = append(nodes, term)
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstStageState returns the stage-state the run was in when it failed,
|
||||||
|
// or the current state for runs still in-flight. Used only by the
|
||||||
|
// pre-stage "past" check to decide if a Booting node should render
|
||||||
|
// "passed" even after the run failed further along.
|
||||||
|
func firstStageState(run *model.Run) model.RunState {
|
||||||
|
if run.FailedStage != "" {
|
||||||
|
if s, ok := stageStateByName(run.FailedStage); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return run.State
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageStateByName mirrors orchestrator.StateForStage without the
|
||||||
|
// import (templates can't see orchestrator).
|
||||||
|
func stageStateByName(name string) (model.RunState, bool) {
|
||||||
|
m := map[string]model.RunState{
|
||||||
|
"Inventory": model.StateInventoryCheck,
|
||||||
|
"SpecValidate": model.StateSpecValidate,
|
||||||
|
"SMART": model.StateSMART,
|
||||||
|
"CPUStress": model.StateCPUStress,
|
||||||
|
"Storage": model.StateStorage,
|
||||||
|
"Network": model.StateNetwork,
|
||||||
|
"GPU": model.StateGPU,
|
||||||
|
"PSU": model.StatePSU,
|
||||||
|
"Reporting": model.StateReporting,
|
||||||
|
}
|
||||||
|
s, ok := m[name]
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty
|
||||||
|
// string when the node hasn't started or hasn't finished.
|
||||||
|
func stageDuration(n PipelineNode) string {
|
||||||
|
if n.StartedAt == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
if n.CompletedAt != nil {
|
||||||
|
end = *n.CompletedAt
|
||||||
|
}
|
||||||
|
d := end.Sub(*n.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", int(d/time.Minute))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageMarker returns the single-char glyph shown in the node's dot.
|
||||||
|
// Dots stay colored-via-class; the glyph is redundant-but-helpful.
|
||||||
|
func stageMarker(state string) string {
|
||||||
|
switch state {
|
||||||
|
case "passed":
|
||||||
|
return "✓"
|
||||||
|
case "failed":
|
||||||
|
return "!"
|
||||||
|
case "running":
|
||||||
|
return "●"
|
||||||
|
case "skipped":
|
||||||
|
return "–"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline renders the ordered dot-and-line timeline. The caller wraps
|
||||||
|
// it in a <section id="pipeline-{runID}" sse-swap=...> so the runner can
|
||||||
|
// re-emit the fragment as stages progress.
|
||||||
|
templ Pipeline(nodes []PipelineNode) {
|
||||||
|
<div class="pipeline">
|
||||||
|
for i, n := range nodes {
|
||||||
|
if i > 0 {
|
||||||
|
<div class={ "stage-connector", "stage-connector-" + nodes[i-1].State }></div>
|
||||||
|
}
|
||||||
|
<div class={ "stage-node", "stage-node-" + n.State }>
|
||||||
|
<div class={ "stage-dot", "stage-dot-" + n.State }>{ stageMarker(n.State) }</div>
|
||||||
|
<div class="stage-name">{ n.Name }</div>
|
||||||
|
<div class="stage-duration">{ stageDuration(n) }</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPipelineString is the one-shot renderer the orchestrator
|
||||||
|
// registers at startup so it can publish pipeline fragments over SSE
|
||||||
|
// without pulling in the template package directly.
|
||||||
|
func RenderPipelineString(run *model.Run, stages []model.Stage) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = Pipeline(BuildPipeline(run, stages)).Render(context.Background(), &buf)
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PipelineNode is one dot on the detail-page timeline. The template
|
||||||
|
// doesn't know stages from pre-stages — it just renders whatever the
|
||||||
|
// BuildPipeline helper produces, in order.
|
||||||
|
type PipelineNode struct {
|
||||||
|
Name string
|
||||||
|
State string // pending|running|passed|failed|skipped
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// preStageOrder are the nodes that show before the first real stage.
|
||||||
|
// Derived from run.State rather than stage rows since we don't persist
|
||||||
|
// pre-stage timestamps.
|
||||||
|
var preStageOrder = []model.RunState{
|
||||||
|
model.StateQueued,
|
||||||
|
model.StateWaitingWoL,
|
||||||
|
model.StateBooting,
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStateRank returns how far along the state machine a run is, using
|
||||||
|
// a linear ranking across pre-stages, stage states, and terminals. Used
|
||||||
|
// by BuildPipeline to decide which pre-stage nodes are "past" (passed),
|
||||||
|
// "current" (running), or "pending".
|
||||||
|
func runStateRank(s model.RunState) int {
|
||||||
|
order := []model.RunState{
|
||||||
|
model.StateRegistered,
|
||||||
|
model.StateQueued,
|
||||||
|
model.StateWaitingWoL,
|
||||||
|
model.StateBooting,
|
||||||
|
model.StateInventoryCheck,
|
||||||
|
model.StateSpecValidate,
|
||||||
|
model.StateSMART,
|
||||||
|
model.StateCPUStress,
|
||||||
|
model.StateStorage,
|
||||||
|
model.StateNetwork,
|
||||||
|
model.StateGPU,
|
||||||
|
model.StatePSU,
|
||||||
|
model.StateReporting,
|
||||||
|
model.StateCompleted,
|
||||||
|
}
|
||||||
|
for i, v := range order {
|
||||||
|
if v == s {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPipeline projects (run, stages) into a linear slice of nodes
|
||||||
|
// covering the whole lifecycle: pre-stage → stage rows → Completed.
|
||||||
|
//
|
||||||
|
// When run == nil we emit a ghost timeline (everything pending) so a
|
||||||
|
// never-run host still shows what's coming.
|
||||||
|
func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
||||||
|
nodes := make([]PipelineNode, 0, len(preStageOrder)+len(stages)+1)
|
||||||
|
|
||||||
|
// --- pre-stage nodes ---
|
||||||
|
for _, ps := range preStageOrder {
|
||||||
|
n := PipelineNode{Name: string(ps), State: "pending"}
|
||||||
|
if run != nil {
|
||||||
|
switch {
|
||||||
|
case run.State == model.StateFailedHolding || run.State == model.StateFailed:
|
||||||
|
// If we failed before reaching a stage, a pre-stage may
|
||||||
|
// still have been entered — keep the "past" rank logic.
|
||||||
|
if runStateRank(ps) < runStateRank(firstStageState(run)) {
|
||||||
|
n.State = "passed"
|
||||||
|
}
|
||||||
|
case run.State == ps:
|
||||||
|
n.State = "running"
|
||||||
|
case runStateRank(run.State) > runStateRank(ps):
|
||||||
|
n.State = "passed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- stage nodes (from stage rows) ---
|
||||||
|
failedBefore := false
|
||||||
|
for _, st := range stages {
|
||||||
|
n := PipelineNode{
|
||||||
|
Name: st.Name,
|
||||||
|
StartedAt: st.StartedAt,
|
||||||
|
CompletedAt: st.CompletedAt,
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case failedBefore:
|
||||||
|
n.State = "skipped"
|
||||||
|
case st.State == model.StagePassed:
|
||||||
|
n.State = "passed"
|
||||||
|
case st.State == model.StageRunning:
|
||||||
|
n.State = "running"
|
||||||
|
case st.State == model.StageFailed:
|
||||||
|
n.State = "failed"
|
||||||
|
failedBefore = true
|
||||||
|
case st.State == model.StageSkipped:
|
||||||
|
n.State = "skipped"
|
||||||
|
default:
|
||||||
|
n.State = "pending"
|
||||||
|
}
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- terminal Completed node ---
|
||||||
|
term := PipelineNode{Name: "Completed", State: "pending"}
|
||||||
|
if run != nil && run.State == model.StateCompleted {
|
||||||
|
term.State = "passed"
|
||||||
|
term.CompletedAt = run.CompletedAt
|
||||||
|
}
|
||||||
|
nodes = append(nodes, term)
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstStageState returns the stage-state the run was in when it failed,
|
||||||
|
// or the current state for runs still in-flight. Used only by the
|
||||||
|
// pre-stage "past" check to decide if a Booting node should render
|
||||||
|
// "passed" even after the run failed further along.
|
||||||
|
func firstStageState(run *model.Run) model.RunState {
|
||||||
|
if run.FailedStage != "" {
|
||||||
|
if s, ok := stageStateByName(run.FailedStage); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return run.State
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageStateByName mirrors orchestrator.StateForStage without the
|
||||||
|
// import (templates can't see orchestrator).
|
||||||
|
func stageStateByName(name string) (model.RunState, bool) {
|
||||||
|
m := map[string]model.RunState{
|
||||||
|
"Inventory": model.StateInventoryCheck,
|
||||||
|
"SpecValidate": model.StateSpecValidate,
|
||||||
|
"SMART": model.StateSMART,
|
||||||
|
"CPUStress": model.StateCPUStress,
|
||||||
|
"Storage": model.StateStorage,
|
||||||
|
"Network": model.StateNetwork,
|
||||||
|
"GPU": model.StateGPU,
|
||||||
|
"PSU": model.StatePSU,
|
||||||
|
"Reporting": model.StateReporting,
|
||||||
|
}
|
||||||
|
s, ok := m[name]
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageDuration renders node timing as "1.2s" / "12s" / "4m". Empty
|
||||||
|
// string when the node hasn't started or hasn't finished.
|
||||||
|
func stageDuration(n PipelineNode) string {
|
||||||
|
if n.StartedAt == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
if n.CompletedAt != nil {
|
||||||
|
end = *n.CompletedAt
|
||||||
|
}
|
||||||
|
d := end.Sub(*n.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", int(d/time.Minute))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageMarker returns the single-char glyph shown in the node's dot.
|
||||||
|
// Dots stay colored-via-class; the glyph is redundant-but-helpful.
|
||||||
|
func stageMarker(state string) string {
|
||||||
|
switch state {
|
||||||
|
case "passed":
|
||||||
|
return "✓"
|
||||||
|
case "failed":
|
||||||
|
return "!"
|
||||||
|
case "running":
|
||||||
|
return "●"
|
||||||
|
case "skipped":
|
||||||
|
return "–"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline renders the ordered dot-and-line timeline. The caller wraps
|
||||||
|
// it in a <section id="pipeline-{runID}" sse-swap=...> so the runner can
|
||||||
|
// re-emit the fragment as stages progress.
|
||||||
|
func Pipeline(nodes []PipelineNode) 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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"pipeline\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for i, n := range nodes {
|
||||||
|
if i > 0 {
|
||||||
|
var templ_7745c5c3_Var2 = []any{"stage-connector", "stage-connector-" + nodes[i-1].State}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, 3, "\"></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 = []any{"stage-node", "stage-node-" + n.State}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, 6, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 = []any{"stage-dot", "stage-dot-" + n.State}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, 8, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 210, Col: 77}
|
||||||
|
}
|
||||||
|
_, 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, "</div><div class=\"stage-name\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 211, Col: 36}
|
||||||
|
}
|
||||||
|
_, 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, "</div><div class=\"stage-duration\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 212, Col: 50}
|
||||||
|
}
|
||||||
|
_, 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, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderPipelineString is the one-shot renderer the orchestrator
|
||||||
|
// registers at startup so it can publish pipeline fragments over SSE
|
||||||
|
// without pulling in the template package directly.
|
||||||
|
func RenderPipelineString(run *model.Run, stages []model.Stage) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = Pipeline(BuildPipeline(run, stages)).Render(context.Background(), &buf)
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// node indexes for the default pipeline layout: pre-stages (3) + stage
|
||||||
|
// rows (9) + terminal Completed (1) = 13 nodes.
|
||||||
|
const (
|
||||||
|
idxQueued = 0
|
||||||
|
idxWaitingWoL = 1
|
||||||
|
idxBooting = 2
|
||||||
|
idxInventory = 3
|
||||||
|
idxSpecValidate = 4
|
||||||
|
idxSMART = 5
|
||||||
|
idxCPUStress = 6
|
||||||
|
idxStorage = 7
|
||||||
|
idxNetwork = 8
|
||||||
|
idxGPU = 9
|
||||||
|
idxPSU = 10
|
||||||
|
idxReporting = 11
|
||||||
|
idxCompleted = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedStages returns a fresh all-pending stage slice in the canonical order.
|
||||||
|
func seedStages() []model.Stage {
|
||||||
|
names := []string{"Inventory", "SpecValidate", "SMART", "CPUStress", "Storage", "Network", "GPU", "PSU", "Reporting"}
|
||||||
|
out := make([]model.Stage, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
out[i] = model.Stage{Name: n, Ordinal: i, State: model.StagePending}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPipeline_NoRun(t *testing.T) {
|
||||||
|
nodes := BuildPipeline(nil, nil)
|
||||||
|
if len(nodes) != len(preStageOrder)+1 {
|
||||||
|
// No stage rows = just pre-stages + Completed.
|
||||||
|
t.Fatalf("len = %d, want %d", len(nodes), len(preStageOrder)+1)
|
||||||
|
}
|
||||||
|
for i, n := range nodes {
|
||||||
|
if n.State != "pending" {
|
||||||
|
t.Errorf("node %d (%s) state = %q, want pending", i, n.Name, n.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPipeline_Running(t *testing.T) {
|
||||||
|
run := &model.Run{State: model.StateSMART}
|
||||||
|
stages := seedStages()
|
||||||
|
stages[0].State = model.StagePassed
|
||||||
|
stages[1].State = model.StagePassed
|
||||||
|
stages[2].State = model.StageRunning
|
||||||
|
nodes := BuildPipeline(run, stages)
|
||||||
|
if len(nodes) != 13 {
|
||||||
|
t.Fatalf("len = %d, want 13", len(nodes))
|
||||||
|
}
|
||||||
|
// Pre-stages are all past for a run that has reached SMART.
|
||||||
|
for i := idxQueued; i <= idxBooting; i++ {
|
||||||
|
if nodes[i].State != "passed" {
|
||||||
|
t.Errorf("prestage %s = %q, want passed", nodes[i].Name, nodes[i].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nodes[idxInventory].State != "passed" {
|
||||||
|
t.Errorf("Inventory = %q, want passed", nodes[idxInventory].State)
|
||||||
|
}
|
||||||
|
if nodes[idxSpecValidate].State != "passed" {
|
||||||
|
t.Errorf("SpecValidate = %q, want passed", nodes[idxSpecValidate].State)
|
||||||
|
}
|
||||||
|
if nodes[idxSMART].State != "running" {
|
||||||
|
t.Errorf("SMART = %q, want running", nodes[idxSMART].State)
|
||||||
|
}
|
||||||
|
if nodes[idxCPUStress].State != "pending" {
|
||||||
|
t.Errorf("CPUStress = %q, want pending", nodes[idxCPUStress].State)
|
||||||
|
}
|
||||||
|
if nodes[idxCompleted].State != "pending" {
|
||||||
|
t.Errorf("Completed = %q, want pending", nodes[idxCompleted].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPipeline_Failed(t *testing.T) {
|
||||||
|
run := &model.Run{State: model.StateFailedHolding, FailedStage: "Storage"}
|
||||||
|
stages := seedStages()
|
||||||
|
for i := 0; i <= 3; i++ {
|
||||||
|
stages[i].State = model.StagePassed
|
||||||
|
}
|
||||||
|
stages[4].State = model.StageFailed // Storage
|
||||||
|
nodes := BuildPipeline(run, stages)
|
||||||
|
// Pre-stages are past a run that reached Storage.
|
||||||
|
for i := idxQueued; i <= idxBooting; i++ {
|
||||||
|
if nodes[i].State != "passed" {
|
||||||
|
t.Errorf("prestage %s = %q, want passed", nodes[i].Name, nodes[i].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nodes[idxStorage].State != "failed" {
|
||||||
|
t.Errorf("Storage = %q, want failed", nodes[idxStorage].State)
|
||||||
|
}
|
||||||
|
for _, i := range []int{idxNetwork, idxGPU, idxPSU, idxReporting} {
|
||||||
|
if nodes[i].State != "skipped" {
|
||||||
|
t.Errorf("%s = %q, want skipped", nodes[i].Name, nodes[i].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nodes[idxCompleted].State != "pending" {
|
||||||
|
t.Errorf("Completed = %q, want pending on failure", nodes[idxCompleted].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPipeline_Completed(t *testing.T) {
|
||||||
|
run := &model.Run{State: model.StateCompleted}
|
||||||
|
stages := seedStages()
|
||||||
|
for i := range stages {
|
||||||
|
stages[i].State = model.StagePassed
|
||||||
|
}
|
||||||
|
nodes := BuildPipeline(run, stages)
|
||||||
|
for i, n := range nodes {
|
||||||
|
if n.State != "passed" {
|
||||||
|
t.Errorf("node %d (%s) state = %q, want passed", i, n.Name, n.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPipeline_QueuedNow(t *testing.T) {
|
||||||
|
run := &model.Run{State: model.StateQueued}
|
||||||
|
nodes := BuildPipeline(run, seedStages())
|
||||||
|
if nodes[idxQueued].State != "running" {
|
||||||
|
t.Errorf("Queued = %q, want running", nodes[idxQueued].State)
|
||||||
|
}
|
||||||
|
if nodes[idxWaitingWoL].State != "pending" {
|
||||||
|
t.Errorf("WaitingWoL = %q, want pending", nodes[idxWaitingWoL].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 19, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 19, Col: 35}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -83,7 +83,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 25, Col: 108}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 25, Col: 108}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -101,7 +101,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 34, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 34, Col: 54}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -114,7 +114,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 38, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 52}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -127,7 +127,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 43, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 43, Col: 77}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -140,7 +140,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 47, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 77}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -153,7 +153,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 52, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 52, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -166,7 +166,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 56, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user