ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.
Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.
Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ type Agent struct {
|
||||
Hosts *store.Hosts
|
||||
Runs *store.Runs
|
||||
Stages *store.Stages
|
||||
SubSteps *store.SubSteps
|
||||
Artifacts *store.Artifacts
|
||||
SpecDiffs *store.SpecDiffs
|
||||
Measurements *store.Measurements
|
||||
@@ -386,12 +387,30 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
|
||||
// DefaultStageOrder); Passed drives StageCompleted vs StageFailed.
|
||||
// Inventory is optional and only set when kind == "Inventory" — the
|
||||
// orchestrator persists it as an artifact and feeds it to spec.Diff.
|
||||
//
|
||||
// SubSteps is agent-authored granular rows (CPU/Memory pass, per-disk
|
||||
// SMART, per-device GPU, …). Empty for stages with no natural
|
||||
// breakdown. Persisted after the mismatch guard fires; per-row SSE is
|
||||
// emitted at the same time so the detail pane can surface them without
|
||||
// a full page reload.
|
||||
type StageResult struct {
|
||||
Stage string `json:"stage"`
|
||||
Passed bool `json:"passed"`
|
||||
Summary json.RawMessage `json:"summary,omitempty"`
|
||||
Inventory *spec.Inventory `json:"inventory,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Stage string `json:"stage"`
|
||||
Passed bool `json:"passed"`
|
||||
Summary json.RawMessage `json:"summary,omitempty"`
|
||||
Inventory *spec.Inventory `json:"inventory,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
SubSteps []SubStepResultLine `json:"sub_steps,omitempty"`
|
||||
}
|
||||
|
||||
// SubStepResultLine is one entry in StageResult.SubSteps. Ordinal is
|
||||
// assigned from slice index server-side; the agent doesn't set it.
|
||||
type SubStepResultLine struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
Summary json.RawMessage `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// Result receives a stage's outcome. Flow:
|
||||
@@ -470,6 +489,12 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Agent-authored sub-steps: persist in slice order (ordinal = index)
|
||||
// and fan out a per-row SSE event each so the detail pane shows them
|
||||
// without a reload. Best-effort — a persistence error is logged but
|
||||
// doesn't fail the whole /result.
|
||||
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
|
||||
|
||||
// Inventory-specific: persist artifact + compute spec diff.
|
||||
if body.Stage == "Inventory" && body.Inventory != nil {
|
||||
if err := a.persistInventory(r, run, body.Inventory); err != nil {
|
||||
@@ -531,6 +556,65 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": string(next)})
|
||||
}
|
||||
|
||||
// persistSubSteps writes each reported sub-step as a row keyed by
|
||||
// (runID, stage, ordinal) where ordinal is the slice index, then emits
|
||||
// a per-row SSE event so an open detail page updates without a reload.
|
||||
// Silently no-ops when SubSteps is unwired (tests that don't supply a
|
||||
// store) or the slice is empty.
|
||||
func (a *Agent) persistSubSteps(ctx context.Context, runID int64, stage string, lines []SubStepResultLine) {
|
||||
if a.SubSteps == nil || len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
for i, line := range lines {
|
||||
state := model.StagePassed
|
||||
switch {
|
||||
case line.Skipped:
|
||||
state = model.StageSkipped
|
||||
case !line.Passed:
|
||||
state = model.StageFailed
|
||||
}
|
||||
started := parseResultTime(line.StartedAt)
|
||||
completed := parseResultTime(line.CompletedAt)
|
||||
summaryJSON := ""
|
||||
if len(line.Summary) > 0 {
|
||||
summaryJSON = string(line.Summary)
|
||||
}
|
||||
ss := model.SubStep{
|
||||
RunID: runID,
|
||||
StageName: stage,
|
||||
Ordinal: i,
|
||||
Name: line.Name,
|
||||
State: state,
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
SummaryJSON: summaryJSON,
|
||||
}
|
||||
if err := a.SubSteps.Upsert(ctx, ss); err != nil {
|
||||
log.Printf("substep upsert run=%d stage=%s ord=%d: %v", runID, stage, i, err)
|
||||
continue
|
||||
}
|
||||
if a.Runner != nil {
|
||||
a.Runner.PublishSubStepUpdate(ctx, ss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseResultTime tolerates RFC3339 / RFC3339Nano and returns nil for
|
||||
// empty or unparseable values so a missing timestamp doesn't block the
|
||||
// persist path.
|
||||
func parseResultTime(s string) *time.Time {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) persistInventory(r *http.Request, run *model.Run, inv *spec.Inventory) error {
|
||||
dir := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", run.ID))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -32,6 +33,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) {
|
||||
hosts := &store.Hosts{DB: conn}
|
||||
runs := &store.Runs{DB: conn}
|
||||
meas := &store.Measurements{DB: conn}
|
||||
subSteps := &store.SubSteps{DB: conn}
|
||||
|
||||
hostID, err := hosts.Create(context.Background(), model.Host{
|
||||
Name: "t-host",
|
||||
@@ -55,6 +57,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) {
|
||||
Hosts: hosts,
|
||||
Runs: runs,
|
||||
Measurements: meas,
|
||||
SubSteps: subSteps,
|
||||
}, runID, plain
|
||||
}
|
||||
|
||||
@@ -215,3 +218,73 @@ func TestResult_AcceptsMatchingStage(t *testing.T) {
|
||||
t.Fatalf("run state = %q, want CPUStress after SMART pass", after.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResult_PersistsSubSteps covers the /result handler's contract for
|
||||
// the new sub_steps table: when the agent includes a sub_steps array in
|
||||
// the POST body, each entry lands in the table with an ordinal equal to
|
||||
// its slice index, state derived from passed/skipped, and timestamps
|
||||
// parsed from RFC3339. The guard must let the call through (matching
|
||||
// stage) and sub-steps are written *after* CompleteStage so a persistence
|
||||
// error doesn't wedge the whole run.
|
||||
func TestResult_PersistsSubSteps(t *testing.T) {
|
||||
a, runID, token := setupAgent(t)
|
||||
a.Runner = &orchestrator.Runner{Runs: a.Runs, Hosts: a.Hosts, Stages: &store.Stages{DB: a.Runs.DB}, EventHub: events.NewHub()}
|
||||
stages := &store.Stages{DB: a.Runs.DB}
|
||||
if err := stages.Seed(context.Background(), runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
if err := a.Runs.SetState(context.Background(), runID, model.StateCPUStress); err != nil {
|
||||
t.Fatalf("set state: %v", err)
|
||||
}
|
||||
|
||||
start := time.Date(2026, 4, 18, 13, 0, 0, 0, time.UTC)
|
||||
end := start.Add(3 * time.Minute)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"stage": "CPUStress",
|
||||
"passed": true,
|
||||
"sub_steps": []map[string]any{
|
||||
{
|
||||
"name": "CPU pass",
|
||||
"passed": true,
|
||||
"started_at": start.Format(time.RFC3339Nano),
|
||||
"completed_at": end.Format(time.RFC3339Nano),
|
||||
"summary": json.RawMessage(`{"elapsed_secs":180}`),
|
||||
},
|
||||
{
|
||||
"name": "Memory pass",
|
||||
"passed": false,
|
||||
"started_at": end.Format(time.RFC3339Nano),
|
||||
"completed_at": end.Add(2 * time.Minute).Format(time.RFC3339Nano),
|
||||
},
|
||||
},
|
||||
})
|
||||
req := routedRequest(runID, http.MethodPost, "/api/v1/runs/"+strconv.FormatInt(runID, 10)+"/result", body)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
a.Result(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rows, err := a.SubSteps.ListForRun(context.Background(), runID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForRun: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("got %d sub-steps, want 2", len(rows))
|
||||
}
|
||||
if rows[0].Ordinal != 0 || rows[0].Name != "CPU pass" || rows[0].State != model.StagePassed {
|
||||
t.Fatalf("row[0] = %+v", rows[0])
|
||||
}
|
||||
if rows[1].Ordinal != 1 || rows[1].Name != "Memory pass" || rows[1].State != model.StageFailed {
|
||||
t.Fatalf("row[1] = %+v", rows[1])
|
||||
}
|
||||
if rows[0].StartedAt == nil || !rows[0].StartedAt.Equal(start) {
|
||||
t.Fatalf("row[0].StartedAt = %v, want %v", rows[0].StartedAt, start)
|
||||
}
|
||||
if rows[0].SummaryJSON != `{"elapsed_secs":180}` {
|
||||
t.Fatalf("row[0].SummaryJSON = %q", rows[0].SummaryJSON)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,17 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"vetting/internal/api"
|
||||
"vetting/internal/db"
|
||||
"vetting/internal/events"
|
||||
"vetting/internal/logs"
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/orchestrator"
|
||||
"vetting/internal/store"
|
||||
@@ -21,7 +24,8 @@ import (
|
||||
|
||||
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
||||
tmp := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(tmp, "vetting.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
@@ -29,18 +33,26 @@ func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
||||
hosts := &store.Hosts{DB: conn}
|
||||
runs := &store.Runs{DB: conn}
|
||||
stages := &store.Stages{DB: conn}
|
||||
subSteps := &store.SubSteps{DB: conn}
|
||||
diffs := &store.SpecDiffs{DB: conn}
|
||||
arts := &store.Artifacts{DB: conn}
|
||||
hub := events.NewHub()
|
||||
logsHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub)
|
||||
if err != nil {
|
||||
t.Fatalf("logs hub: %v", err)
|
||||
}
|
||||
t.Cleanup(logsHub.Close)
|
||||
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
||||
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
|
||||
ui := &api.UI{
|
||||
Hosts: hosts,
|
||||
Runs: runs,
|
||||
Stages: stages,
|
||||
SubSteps: subSteps,
|
||||
SpecDiffs: diffs,
|
||||
Artifacts: arts,
|
||||
EventHub: hub,
|
||||
Logs: logsHub,
|
||||
Runner: runner,
|
||||
Tiles: tiles,
|
||||
}
|
||||
@@ -54,6 +66,16 @@ func detailReq(id int64) *http.Request {
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
// detailReqWithQuery is detailReq with an optional ?run= query string.
|
||||
// Used by TestHostDetail_RunQueryParam so we can drive the selected-run
|
||||
// branch without routing through the real router.
|
||||
func detailReqWithQuery(id int64, rawQuery string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d?%s", id, rawQuery), nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", fmt.Sprintf("%d", id))
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestHostDetail_OK(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
@@ -71,6 +93,9 @@ func TestHostDetail_OK(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
@@ -85,7 +110,8 @@ func TestHostDetail_OK(t *testing.T) {
|
||||
if !strings.Contains(body, wantPipelineID) {
|
||||
t.Fatalf("body missing %s", wantPipelineID)
|
||||
}
|
||||
wantLogID := fmt.Sprintf(`id="log-%d"`, runID)
|
||||
// Each stage owns its own log pane; assert one of them is present.
|
||||
wantLogID := fmt.Sprintf(`id="log-%d-Inventory"`, runID)
|
||||
if !strings.Contains(body, wantLogID) {
|
||||
t.Fatalf("body missing %s", wantLogID)
|
||||
}
|
||||
@@ -121,14 +147,16 @@ func TestHostDetail_NeverRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostDetail_LogTabsRendered: when a run exists, the detail page
|
||||
// emits the log-tabs scaffold with one radio per stage + an "All" tab
|
||||
// checked by default. CSS sibling selectors drive visibility — no JS.
|
||||
func TestHostDetail_LogTabsRendered(t *testing.T) {
|
||||
// TestHostDetail_ActiveStepsRendered: every canonical stage gets its own
|
||||
// <details data-stage="..."> panel with a matching log pane id, replacing
|
||||
// the old flat log-tab scaffold. Also confirms the sub-step SSE swap
|
||||
// target exists when sub-steps are seeded for a stage (so Phase 1's
|
||||
// substep-* event path has a DOM home).
|
||||
func TestHostDetail_ActiveStepsRendered(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "tabs-host",
|
||||
Name: "steps-host",
|
||||
MAC: "aa:bb:cc:dd:ee:40",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
@@ -141,6 +169,19 @@ func TestHostDetail_LogTabsRendered(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
// Seed one CPUStress sub-step so the SubStepRow swap target lands.
|
||||
if err := ui.SubSteps.Upsert(ctx, model.SubStep{
|
||||
RunID: runID,
|
||||
StageName: "CPUStress",
|
||||
Ordinal: 0,
|
||||
Name: "CPU pass",
|
||||
State: model.StagePending,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert sub-step: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
@@ -149,23 +190,246 @@ func TestHostDetail_LogTabsRendered(t *testing.T) {
|
||||
}
|
||||
body := rr.Body.String()
|
||||
|
||||
// All tab: the default-checked radio, plus its pane.
|
||||
wantAllID := fmt.Sprintf(`id="log-tab-%d-all"`, runID)
|
||||
if !strings.Contains(body, wantAllID) {
|
||||
t.Fatalf("body missing All tab radio %s", wantAllID)
|
||||
}
|
||||
// Per-stage tabs: every entry in DefaultStageOrder must have its own
|
||||
// radio + pane so tabs switch purely via sibling CSS.
|
||||
// Every stage in DefaultStageOrder owns a collapsible panel + log pane.
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
wantRadio := fmt.Sprintf(`id="log-tab-%d-%s"`, runID, s)
|
||||
if !strings.Contains(body, wantRadio) {
|
||||
t.Fatalf("body missing stage tab radio %s", wantRadio)
|
||||
wantPanel := fmt.Sprintf(`data-stage="%s"`, s)
|
||||
if !strings.Contains(body, wantPanel) {
|
||||
t.Fatalf("body missing active-step panel %s", wantPanel)
|
||||
}
|
||||
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
|
||||
if !strings.Contains(body, wantPane) {
|
||||
t.Fatalf("body missing stage pane %s", wantPane)
|
||||
t.Fatalf("body missing stage log pane %s", wantPane)
|
||||
}
|
||||
}
|
||||
// Sub-step row for CPUStress/0 is present and SSE-bound.
|
||||
wantSub := fmt.Sprintf(`id="substep-%d-CPUStress-0"`, runID)
|
||||
if !strings.Contains(body, wantSub) {
|
||||
t.Fatalf("body missing sub-step row %s", wantSub)
|
||||
}
|
||||
wantSubSwap := fmt.Sprintf(`sse-swap="substep-%d-CPUStress-0"`, runID)
|
||||
if !strings.Contains(body, wantSubSwap) {
|
||||
t.Fatalf("body missing sub-step sse-swap %s", wantSubSwap)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultOpenStage returns the value of data-stage on the single
|
||||
// `<details ... open data-stage="...">` emitted by ActiveStep. Returns
|
||||
// "" if no stage is currently open. The rendered attribute order is
|
||||
// fixed by active_step.templ (class, then open?, then data-stage), so
|
||||
// a tight substring match is safe.
|
||||
func defaultOpenStage(body string) string {
|
||||
re := regexp.MustCompile(`open data-stage="([^"]+)"`)
|
||||
m := re.FindStringSubmatch(body)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func TestHostDetail_DefaultStep_Running(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "default-running",
|
||||
MAC: "aa:bb:cc:dd:ee:50",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(ctx, id, "t-running", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
// Earlier stages passed; SMART is running.
|
||||
for _, name := range []string{"Inventory", "SpecValidate"} {
|
||||
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
|
||||
t.Fatalf("start %s: %v", name, err)
|
||||
}
|
||||
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
|
||||
t.Fatalf("complete %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := ui.Stages.StartByName(ctx, runID, "SMART"); err != nil {
|
||||
t.Fatalf("start SMART: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "SMART" {
|
||||
t.Fatalf("default step = %q, want SMART", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetail_DefaultStep_Failed(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "default-failed",
|
||||
MAC: "aa:bb:cc:dd:ee:51",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(ctx, id, "t-failed", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
// Inventory + SpecValidate + SMART passed; CPUStress failed; nothing
|
||||
// running. Default must land on CPUStress.
|
||||
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
|
||||
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
|
||||
t.Fatalf("start %s: %v", name, err)
|
||||
}
|
||||
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
|
||||
t.Fatalf("complete %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := ui.Stages.StartByName(ctx, runID, "CPUStress"); err != nil {
|
||||
t.Fatalf("start CPUStress: %v", err)
|
||||
}
|
||||
if err := ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`); err != nil {
|
||||
t.Fatalf("complete CPUStress: %v", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" {
|
||||
t.Fatalf("default step = %q, want CPUStress", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetail_DefaultStep_Completed(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "default-done",
|
||||
MAC: "aa:bb:cc:dd:ee:52",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(ctx, id, "t-done", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
// All stages passed → default lands on Reporting.
|
||||
for _, name := range store.DefaultStageOrder {
|
||||
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
|
||||
t.Fatalf("start %s: %v", name, err)
|
||||
}
|
||||
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
|
||||
t.Fatalf("complete %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||
t.Fatalf("default step = %q, want Reporting", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostDetail_RunQueryParam: ?run=N selects a specific past run
|
||||
// instead of the latest. The history sidebar's links rely on this.
|
||||
func TestHostDetail_RunQueryParam(t *testing.T) {
|
||||
ui, hosts, runs := setupDetail(t)
|
||||
ctx := context.Background()
|
||||
id, err := hosts.Create(ctx, model.Host{
|
||||
Name: "query-run",
|
||||
MAC: "aa:bb:cc:dd:ee:53",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
// Older run: failed at CPUStress. Newer run: fully passed.
|
||||
oldRun, err := runs.Create(ctx, id, "old", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create old run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, oldRun); err != nil {
|
||||
t.Fatalf("seed old: %v", err)
|
||||
}
|
||||
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
|
||||
_ = ui.Stages.StartByName(ctx, oldRun, name)
|
||||
_ = ui.Stages.CompleteByName(ctx, oldRun, name, model.StagePassed, "")
|
||||
}
|
||||
_ = ui.Stages.StartByName(ctx, oldRun, "CPUStress")
|
||||
_ = ui.Stages.CompleteByName(ctx, oldRun, "CPUStress", model.StageFailed, "")
|
||||
|
||||
// Newer run lands after a tiny gap so Runs.LatestForHost picks it.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
newRun, err := runs.Create(ctx, id, "new", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create new run: %v", err)
|
||||
}
|
||||
if err := ui.Stages.Seed(ctx, newRun); err != nil {
|
||||
t.Fatalf("seed new: %v", err)
|
||||
}
|
||||
for _, name := range store.DefaultStageOrder {
|
||||
_ = ui.Stages.StartByName(ctx, newRun, name)
|
||||
_ = ui.Stages.CompleteByName(ctx, newRun, name, model.StagePassed, "")
|
||||
}
|
||||
|
||||
// Sanity: with no ?run=, default is Reporting (latest run is green).
|
||||
rr := httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReq(id))
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||
t.Fatalf("latest default = %q, want Reporting", got)
|
||||
}
|
||||
|
||||
// With ?run={old}, we view the failed run → default is CPUStress and
|
||||
// the pipeline section references the old run's ID.
|
||||
rr = httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReqWithQuery(id, fmt.Sprintf("run=%d", oldRun)))
|
||||
body := rr.Body.String()
|
||||
if got := defaultOpenStage(body); got != "CPUStress" {
|
||||
t.Fatalf("?run=old default = %q, want CPUStress", got)
|
||||
}
|
||||
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, oldRun)
|
||||
if !strings.Contains(body, wantPipelineID) {
|
||||
t.Fatalf("?run=old body missing %s", wantPipelineID)
|
||||
}
|
||||
|
||||
// A ?run= value that belongs to no host at all falls back to latest
|
||||
// silently (stale bookmark should never 4xx).
|
||||
rr = httptest.NewRecorder()
|
||||
ui.HostDetail(rr, detailReqWithQuery(id, "run=9999"))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("bogus run fallback status = %d", rr.Code)
|
||||
}
|
||||
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||
t.Fatalf("bogus run default = %q, want Reporting (fall back to latest)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDetail_UnknownID(t *testing.T) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// place that renders a tile shows the same data.
|
||||
type TileEnricher struct {
|
||||
Runs *store.Runs
|
||||
Stages *store.Stages
|
||||
Artifacts *store.Artifacts
|
||||
SpecDiffs *store.SpecDiffs
|
||||
}
|
||||
@@ -53,6 +54,16 @@ func (e *TileEnricher) Build(ctx context.Context, host model.Host, latest *model
|
||||
log.Printf("tile: list artifacts run %d: %v", latest.ID, err)
|
||||
}
|
||||
}
|
||||
// Stage row per canonical stage drives the dashboard tile's mini
|
||||
// run-view strip. Fail-soft: a DB hiccup renders the tile without
|
||||
// dots rather than breaking the whole dashboard.
|
||||
if e.Stages != nil {
|
||||
if stages, err := e.Stages.ListForRun(ctx, latest.ID); err == nil {
|
||||
t.Stages = stages
|
||||
} else {
|
||||
log.Printf("tile: list stages run %d: %v", latest.ID, err)
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
|
||||
+72
-12
@@ -28,6 +28,7 @@ type UI struct {
|
||||
Hosts *store.Hosts
|
||||
Runs *store.Runs
|
||||
Stages *store.Stages
|
||||
SubSteps *store.SubSteps
|
||||
SpecDiffs *store.SpecDiffs
|
||||
Artifacts *store.Artifacts
|
||||
EventHub *events.Hub
|
||||
@@ -118,7 +119,16 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data, err := u.LoadHostDetailData(r.Context(), id)
|
||||
// Optional ?run=N: select a specific past run instead of the latest.
|
||||
// Rejected runs (bad parse, wrong host) fall back to latest silently
|
||||
// so a stale bookmark doesn't 404.
|
||||
var selectedRunID int64
|
||||
if q := r.URL.Query().Get("run"); q != "" {
|
||||
if parsed, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||
selectedRunID = parsed
|
||||
}
|
||||
}
|
||||
data, err := u.LoadHostDetailData(r.Context(), id, selectedRunID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
@@ -139,7 +149,12 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// diffs, replay, and tile enrichment are fail-soft (empty on error) —
|
||||
// mirrors the original inline behaviour so a transient DB hiccup on one
|
||||
// relation doesn't blank the whole page.
|
||||
func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.HostDetailData, error) {
|
||||
//
|
||||
// selectedRunID == 0 means "use the latest run". A positive value picks
|
||||
// a specific past run for the hosts/{id}?run=N history-sidebar navigation;
|
||||
// if that run doesn't exist or belongs to another host we fall back to
|
||||
// the latest so a stale URL doesn't error out.
|
||||
func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64, selectedRunID int64) (templates.HostDetailData, error) {
|
||||
host, err := u.Hosts.Get(ctx, hostID)
|
||||
if err != nil {
|
||||
return templates.HostDetailData{}, err
|
||||
@@ -148,29 +163,74 @@ func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.Ho
|
||||
if err != nil {
|
||||
return templates.HostDetailData{}, err
|
||||
}
|
||||
// Resolve the viewed run: selectedRunID wins when it matches this
|
||||
// host; otherwise fall back to latest. A run that belongs to a
|
||||
// different host is silently ignored — no operator action should be
|
||||
// able to render another host's run under this page.
|
||||
viewed := latest
|
||||
if selectedRunID > 0 && u.Runs != nil {
|
||||
if r, err := u.Runs.Get(ctx, selectedRunID); err == nil && r != nil && r.HostID == hostID {
|
||||
viewed = r
|
||||
}
|
||||
}
|
||||
var stages []model.Stage
|
||||
var diffs []model.SpecDiff
|
||||
if latest != nil {
|
||||
var subSteps []model.SubStep
|
||||
if viewed != nil {
|
||||
if u.Stages != nil {
|
||||
stages, _ = u.Stages.ListForRun(ctx, latest.ID)
|
||||
stages, _ = u.Stages.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
if u.SpecDiffs != nil {
|
||||
diffs, _ = u.SpecDiffs.ListForRun(ctx, latest.ID)
|
||||
diffs, _ = u.SpecDiffs.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
if u.SubSteps != nil {
|
||||
subSteps, _ = u.SubSteps.ListForRun(ctx, viewed.ID)
|
||||
}
|
||||
}
|
||||
t := u.Tiles.Build(ctx, *host, latest)
|
||||
// Sidebar: last 20 runs for this host, newest first. Fail-soft so a
|
||||
// transient DB error doesn't blank the whole page.
|
||||
var history []model.Run
|
||||
if u.Runs != nil {
|
||||
history, _ = u.Runs.ListForHost(ctx, hostID, 20)
|
||||
}
|
||||
t := u.Tiles.Build(ctx, *host, viewed)
|
||||
replay := ""
|
||||
if latest != nil && u.Logs != nil {
|
||||
replay = u.Logs.Replay(latest.ID)
|
||||
replayByStage := map[string]string{}
|
||||
if viewed != nil && u.Logs != nil {
|
||||
replay = u.Logs.Replay(viewed.ID)
|
||||
replayByStage = u.Logs.ReplayByStage(viewed.ID)
|
||||
}
|
||||
return templates.HostDetailData{
|
||||
Tile: t,
|
||||
Stages: stages,
|
||||
SpecDiffs: diffs,
|
||||
LogReplay: replay,
|
||||
Tile: t,
|
||||
Stages: stages,
|
||||
SpecDiffs: diffs,
|
||||
SubSteps: subSteps,
|
||||
History: history,
|
||||
DefaultStepStage: pickDefaultStep(stages),
|
||||
LogReplay: replay,
|
||||
LogReplayByStage: replayByStage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// pickDefaultStep chooses which stage the detail page opens expanded by
|
||||
// default. Rule: running → first-failed → Reporting. The operator is
|
||||
// almost always most interested in the thing currently happening (or
|
||||
// the thing that just failed); Reporting is the sensible terminal fallback
|
||||
// because it's where the report link lives.
|
||||
func pickDefaultStep(stages []model.Stage) string {
|
||||
for _, s := range stages {
|
||||
if s.State == model.StageRunning {
|
||||
return s.Name
|
||||
}
|
||||
}
|
||||
for _, s := range stages {
|
||||
if s.State == model.StageFailed {
|
||||
return s.Name
|
||||
}
|
||||
}
|
||||
return "Reporting"
|
||||
}
|
||||
|
||||
// StartRun creates a new Run for the host, issues an agent token, and
|
||||
// transitions Registered→Queued. The dispatcher goroutine picks it up
|
||||
// on its next tick; the happy path is heartbeat-driven (the reporter's
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS sub_steps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
||||
stage_name TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
summary_json TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE (run_id, stage_name, ordinal)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sub_steps_run ON sub_steps(run_id, stage_name, ordinal);
|
||||
@@ -143,3 +143,69 @@ func waitForSSEEvent(r *bufio.Reader, name string, timeout time.Duration) error
|
||||
type timeoutErr struct{}
|
||||
|
||||
func (e *timeoutErr) Error() string { return "timeout waiting for sse event" }
|
||||
|
||||
// TestSSE_SubStepEvent confirms PublishSubStepUpdate lands on the wire
|
||||
// with the exact "substep-{runID}-{stage}-{ordinal}" event name that
|
||||
// detail-page swap targets key on. Without this, the template renders
|
||||
// the right attribute but a middleware or renderer regression silently
|
||||
// drops the payload.
|
||||
func TestSSE_SubStepEvent(t *testing.T) {
|
||||
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}
|
||||
|
||||
orchestrator.SubStepRenderer = func(_ model.SubStep) string {
|
||||
return `<div class="substep">row</div>`
|
||||
}
|
||||
|
||||
ui := &api.UI{
|
||||
Hosts: hosts, Runs: runs, Stages: stages, SpecDiffs: diffs, Artifacts: arts,
|
||||
EventHub: hub, Runner: runner, Tiles: tiles,
|
||||
}
|
||||
agent := &api.Agent{
|
||||
Hosts: hosts, Runs: runs, Stages: stages, Artifacts: arts,
|
||||
SpecDiffs: diffs, Runner: runner, EventHub: hub,
|
||||
}
|
||||
|
||||
router := NewRouter(Deps{UI: ui, Agent: agent})
|
||||
srv := httptest.NewServer(router)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET /events: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
if err := waitForSSEEvent(reader, "hello", 1*time.Second); err != nil {
|
||||
t.Fatalf("hello preamble: %v", err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
runner.PublishSubStepUpdate(context.Background(), model.SubStep{
|
||||
RunID: 42,
|
||||
StageName: "CPUStress",
|
||||
Ordinal: 1,
|
||||
Name: "Memory pass",
|
||||
State: model.StagePassed,
|
||||
})
|
||||
|
||||
if err := waitForSSEEvent(reader, "substep-42-CPUStress-1", 2*time.Second); err != nil {
|
||||
t.Fatalf("substep event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+185
-8
@@ -30,6 +30,10 @@ type Writer struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
hub *events.Hub
|
||||
// counters keyed by Stage (empty key = orphan/framing lines) so each
|
||||
// stage's rendered output numbers from 1. Shared with Replay via
|
||||
// seedCounters on Writer open so restarts don't reset to 1 mid-run.
|
||||
counters map[string]int
|
||||
}
|
||||
|
||||
// Hub owns the per-run Writers. The orchestrator creates one Hub at
|
||||
@@ -62,11 +66,47 @@ func (h *Hub) WriterFor(runID int64) (*Writer, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
w := &Writer{runID: runID, f: f, hub: h.events}
|
||||
w := &Writer{runID: runID, f: f, hub: h.events, counters: seedCounters(path)}
|
||||
h.writers[runID] = w
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// seedCounters scans the on-disk log and returns the per-stage line
|
||||
// counts that were already rendered for this run. Called on Writer open
|
||||
// so a mid-run process restart continues numbering where it left off
|
||||
// — otherwise Append(line) would emit "1" but Replay already shows
|
||||
// "42" on the reload, and anchor permalinks would collide.
|
||||
func seedCounters(path string) map[string]int {
|
||||
counters := map[string]int{}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return counters
|
||||
}
|
||||
for _, raw := range strings.Split(string(b), "\n") {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
tsEnd := strings.IndexByte(raw, ' ')
|
||||
if tsEnd < 0 {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimLeft(raw[tsEnd+1:], " ")
|
||||
lvEnd := strings.IndexByte(rest, ' ')
|
||||
if lvEnd < 0 {
|
||||
continue
|
||||
}
|
||||
text := rest[lvEnd+1:]
|
||||
stage := ""
|
||||
if strings.HasPrefix(text, "[") {
|
||||
if end := strings.Index(text, "] "); end > 1 {
|
||||
stage = text[1:end]
|
||||
}
|
||||
}
|
||||
counters[stage]++
|
||||
}
|
||||
return counters
|
||||
}
|
||||
|
||||
// Close flushes and closes all open run files. Called from main on
|
||||
// shutdown so the logs aren't left with buffered data.
|
||||
func (h *Hub) Close() {
|
||||
@@ -92,6 +132,10 @@ func (h *Hub) PathFor(runID int64) string {
|
||||
// the pane just stays empty until live events arrive. Does not subscribe
|
||||
// to the SSE hub — callers are expected to pair this with a live
|
||||
// sse-swap target on the same element.
|
||||
//
|
||||
// Line numbers are rebuilt from scratch here so a page reload shows
|
||||
// stable IDs that match what Append will emit for future lines (the
|
||||
// Writer's counters are seeded from disk on open).
|
||||
func (h *Hub) Replay(runID int64) string {
|
||||
path := h.PathFor(runID)
|
||||
b, err := os.ReadFile(path)
|
||||
@@ -99,6 +143,7 @@ func (h *Hub) Replay(runID int64) string {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
counters := map[string]int{}
|
||||
for _, raw := range strings.Split(string(b), "\n") {
|
||||
if raw == "" {
|
||||
continue
|
||||
@@ -129,7 +174,111 @@ func (h *Hub) Replay(runID int64) string {
|
||||
text = text[end+2:]
|
||||
}
|
||||
}
|
||||
out.WriteString(renderLogSSE(Line{TS: ts, Level: level, Stage: stage, Text: text}))
|
||||
counters[stage]++
|
||||
out.WriteString(renderLogSSE(runID, counters[stage], Line{TS: ts, Level: level, Stage: stage, Text: text}))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// ReplayByStage scans the on-disk log once and returns a per-stage map
|
||||
// of pre-rendered HTML strings, keyed by stage name (orphan/framing
|
||||
// lines are keyed under ""). This is the one-pass alternative to
|
||||
// calling ReplayForStage per stage: the detail-page renders nine stage
|
||||
// panels, and doing nine file scans per page load is wasteful. Line
|
||||
// numbers are per-stage so they agree with the counters Append uses
|
||||
// for the same run.
|
||||
func (h *Hub) ReplayByStage(runID int64) map[string]string {
|
||||
path := h.PathFor(runID)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
bufs := map[string]*strings.Builder{}
|
||||
counters := map[string]int{}
|
||||
for _, raw := range strings.Split(string(b), "\n") {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
tsEnd := strings.IndexByte(raw, ' ')
|
||||
if tsEnd < 0 {
|
||||
continue
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339Nano, raw[:tsEnd])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimLeft(raw[tsEnd+1:], " ")
|
||||
lvEnd := strings.IndexByte(rest, ' ')
|
||||
if lvEnd < 0 {
|
||||
continue
|
||||
}
|
||||
level := strings.ToLower(rest[:lvEnd])
|
||||
text := rest[lvEnd+1:]
|
||||
stage := ""
|
||||
if strings.HasPrefix(text, "[") {
|
||||
if end := strings.Index(text, "] "); end > 1 {
|
||||
stage = text[1:end]
|
||||
text = text[end+2:]
|
||||
}
|
||||
}
|
||||
counters[stage]++
|
||||
sb, ok := bufs[stage]
|
||||
if !ok {
|
||||
sb = &strings.Builder{}
|
||||
bufs[stage] = sb
|
||||
}
|
||||
sb.WriteString(renderLogSSE(runID, counters[stage], Line{TS: ts, Level: level, Stage: stage, Text: text}))
|
||||
}
|
||||
out := make(map[string]string, len(bufs))
|
||||
for k, sb := range bufs {
|
||||
out[k] = sb.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReplayForStage returns only the log lines whose Stage matches stageName
|
||||
// (pass "" for orphan/framing lines). Used by the detail-page ActiveStep
|
||||
// renderer so each expanded step shows only its own log history. Line
|
||||
// numbers here are per-stage so they agree with what Append emits live.
|
||||
func (h *Hub) ReplayForStage(runID int64, stageName string) string {
|
||||
path := h.PathFor(runID)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
ord := 0
|
||||
for _, raw := range strings.Split(string(b), "\n") {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
tsEnd := strings.IndexByte(raw, ' ')
|
||||
if tsEnd < 0 {
|
||||
continue
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339Nano, raw[:tsEnd])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimLeft(raw[tsEnd+1:], " ")
|
||||
lvEnd := strings.IndexByte(rest, ' ')
|
||||
if lvEnd < 0 {
|
||||
continue
|
||||
}
|
||||
level := strings.ToLower(rest[:lvEnd])
|
||||
text := rest[lvEnd+1:]
|
||||
stage := ""
|
||||
if strings.HasPrefix(text, "[") {
|
||||
if end := strings.Index(text, "] "); end > 1 {
|
||||
stage = text[1:end]
|
||||
text = text[end+2:]
|
||||
}
|
||||
}
|
||||
if stage != stageName {
|
||||
continue
|
||||
}
|
||||
ord++
|
||||
out.WriteString(renderLogSSE(runID, ord, Line{TS: ts, Level: level, Stage: stage, Text: text}))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -154,8 +303,13 @@ func (w *Writer) Append(line Line) {
|
||||
if _, err := w.f.WriteString(stamped); err != nil {
|
||||
log.Printf("logs: write run-%d: %v", w.runID, err)
|
||||
}
|
||||
if w.counters == nil {
|
||||
w.counters = map[string]int{}
|
||||
}
|
||||
w.counters[line.Stage]++
|
||||
ord := w.counters[line.Stage]
|
||||
if w.hub != nil {
|
||||
payload := renderLogSSE(line)
|
||||
payload := renderLogSSE(w.runID, ord, line)
|
||||
w.hub.Publish(events.Event{
|
||||
Name: fmt.Sprintf("log-%d", w.runID),
|
||||
Payload: payload,
|
||||
@@ -182,18 +336,41 @@ func (w *Writer) Close() error {
|
||||
|
||||
// renderLogSSE returns an HTMX-compatible fragment. The detail-page
|
||||
// panes contain <div id="log-N-..." hx-swap="beforeend">: each event
|
||||
// appends one <div class="log-line log-LEVEL"> to them. Stage, if set,
|
||||
// is rendered as a dim prefix so the "All" pane stays disambiguable
|
||||
// even with multiple stages interleaved.
|
||||
func renderLogSSE(l Line) string {
|
||||
// appends one <div class="log-line log-LEVEL"> to them. ord is the
|
||||
// per-(run, stage) 1-based line number; combined with runID + stage it
|
||||
// forms a stable permalink id of the form L{run}-{stage}-{ord} (stage
|
||||
// defaults to "all" when the line has no stage, so orphan/framing lines
|
||||
// still anchor uniquely).
|
||||
//
|
||||
// Shape:
|
||||
//
|
||||
// <div class="log-line log-{level}" id="L{run}-{stage}-{ord}" data-ts="RFC3339Nano">
|
||||
// <a class="log-anchor" href="#L{run}-{stage}-{ord}">#</a>
|
||||
// <span class="ln">{ord}</span>
|
||||
// <span class="lvl">{LEVEL}</span>
|
||||
// <span class="log-ts">15:04:05</span>
|
||||
// (optional) <span class="log-stage">[{stage}]</span>
|
||||
// <span class="log-text">{text}</span>
|
||||
// </div>
|
||||
func renderLogSSE(runID int64, ord int, l Line) string {
|
||||
level := strings.ToLower(l.Level)
|
||||
stageKey := l.Stage
|
||||
if stageKey == "" {
|
||||
stageKey = "all"
|
||||
}
|
||||
anchorID := fmt.Sprintf("L%d-%s-%d", runID, html.EscapeString(stageKey), ord)
|
||||
stagePrefix := ""
|
||||
if l.Stage != "" {
|
||||
stagePrefix = fmt.Sprintf(`<span class="log-stage">[%s]</span> `, html.EscapeString(l.Stage))
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`<div class="log-line log-%s">%s %s%s</div>`,
|
||||
`<div class="log-line log-%s" id="%s" data-ts="%s"><a class="log-anchor" href="#%s">#</a><span class="ln">%d</span><span class="lvl">%s</span><span class="log-ts">%s</span>%s<span class="log-text">%s</span></div>`,
|
||||
html.EscapeString(level),
|
||||
anchorID,
|
||||
html.EscapeString(l.TS.Format(time.RFC3339Nano)),
|
||||
anchorID,
|
||||
ord,
|
||||
html.EscapeString(strings.ToUpper(level)),
|
||||
html.EscapeString(l.TS.Format("15:04:05")),
|
||||
stagePrefix,
|
||||
html.EscapeString(l.Text),
|
||||
|
||||
@@ -86,6 +86,24 @@ type Stage struct {
|
||||
SummaryJSON string
|
||||
}
|
||||
|
||||
// SubStep is a finer-grained unit within a Stage, authored by the agent.
|
||||
// Not every stage has sub-steps; those that do (CPUStress, SMART per-disk,
|
||||
// Storage per-disk, GPU per-device) surface them so the UI can render a
|
||||
// GitHub-Actions-style collapsible list. Sub-steps share the StageState
|
||||
// enum with Stage; Ordinal is 0-based within StageName for a given RunID
|
||||
// and is how the UI and SSE events key each row.
|
||||
type SubStep struct {
|
||||
ID int64
|
||||
RunID int64
|
||||
StageName string
|
||||
Ordinal int
|
||||
Name string
|
||||
State StageState
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
SummaryJSON string
|
||||
}
|
||||
|
||||
type Measurement struct {
|
||||
ID int64
|
||||
RunID int64
|
||||
|
||||
@@ -154,6 +154,25 @@ var TileRenderer func(ctx context.Context, host model.Host, latest *model.Run) s
|
||||
// orchestrator stays free of template imports.
|
||||
var PipelineRenderer func(run *model.Run, stages []model.Stage) string
|
||||
|
||||
// SubStepRenderer renders a single sub-step row fragment. Fires on
|
||||
// every sub-step state transition (running → passed/failed) so the
|
||||
// detail page's `<div sse-swap="substep-{runID}-{stage}-{ordinal}">`
|
||||
// target updates without reloading.
|
||||
var SubStepRenderer func(ss model.SubStep) string
|
||||
|
||||
// PublishSubStepUpdate broadcasts a single sub-step row. Callers give
|
||||
// the just-persisted SubStep; we render + fan out. Safe to call when
|
||||
// no renderer is wired; drops silently.
|
||||
func (r *Runner) PublishSubStepUpdate(ctx context.Context, ss model.SubStep) {
|
||||
if SubStepRenderer == nil || r.EventHub == nil {
|
||||
return
|
||||
}
|
||||
r.EventHub.Publish(events.Event{
|
||||
Name: fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal),
|
||||
Payload: SubStepRenderer(ss),
|
||||
})
|
||||
}
|
||||
|
||||
// HostDetailFragments is the pre-rendered bundle of HTML fragments a
|
||||
// single PublishHostDetail call broadcasts over SSE. Summary and Actions
|
||||
// are always set; SpecDiffs and Hold are empty strings when there is no
|
||||
|
||||
@@ -154,6 +154,45 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
// ListForHost returns the most recent `limit` runs for a host, newest
|
||||
// first. Caller uses this to drive the host-detail runs sidebar (last 20
|
||||
// by default, Phase 2). Zero/negative limit falls back to a safe cap so
|
||||
// a mistaken call can't scan the whole history into memory.
|
||||
func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := r.DB.QueryContext(ctx, `
|
||||
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
|
||||
COALESCE(next_boot_target,''), agent_token_hash, started_at,
|
||||
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
|
||||
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
|
||||
FROM runs
|
||||
WHERE host_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
`, hostID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.Run
|
||||
for rows.Next() {
|
||||
var run model.Run
|
||||
var completedAt sql.NullTime
|
||||
if err := rows.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
|
||||
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if completedAt.Valid {
|
||||
run.CompletedAt = &completedAt.Time
|
||||
}
|
||||
out = append(out, run)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Active returns all runs in non-terminal states.
|
||||
func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
|
||||
rows, err := r.DB.QueryContext(ctx, `
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// SubSteps is the persistence layer for the sub_steps table. Sub-steps
|
||||
// are authored by the agent (one per disk in SMART, one per disk-operation
|
||||
// in Storage, CPU+Memory passes in CPUStress, etc.) and surfaced in the
|
||||
// detail page as a collapsible list under each stage.
|
||||
type SubSteps struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// Upsert inserts-or-replaces a sub-step row keyed by (run_id, stage_name,
|
||||
// ordinal). Replace semantics let the agent push a row twice — once when
|
||||
// the sub-step starts (state=running, completed_at=nil) and again when
|
||||
// it finishes (state=passed|failed, completed_at set) — without the
|
||||
// caller needing to track whether the row already exists.
|
||||
func (s *SubSteps) Upsert(ctx context.Context, ss model.SubStep) error {
|
||||
// Preserve the original started_at if the caller didn't supply one
|
||||
// (a "complete" call that only knows the end time still needs to
|
||||
// leave the start time alone). ON CONFLICT DO UPDATE with COALESCE
|
||||
// handles the merge in one round-trip.
|
||||
_, err := s.DB.ExecContext(ctx, `
|
||||
INSERT INTO sub_steps(run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(run_id, stage_name, ordinal) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
state = excluded.state,
|
||||
started_at = COALESCE(excluded.started_at, sub_steps.started_at),
|
||||
completed_at = COALESCE(excluded.completed_at, sub_steps.completed_at),
|
||||
summary_json = CASE WHEN excluded.summary_json = '{}' THEN sub_steps.summary_json ELSE excluded.summary_json END
|
||||
`,
|
||||
ss.RunID, ss.StageName, ss.Ordinal, ss.Name, string(ss.State),
|
||||
nullTime(ss.StartedAt), nullTime(ss.CompletedAt), jsonOrDefault(ss.SummaryJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert sub-step: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a single sub-step by its natural key. Returns ErrNotFound
|
||||
// if none exists.
|
||||
func (s *SubSteps) Get(ctx context.Context, runID int64, stageName string, ordinal int) (*model.SubStep, error) {
|
||||
row := s.DB.QueryRowContext(ctx, `
|
||||
SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
|
||||
FROM sub_steps WHERE run_id = ? AND stage_name = ? AND ordinal = ?
|
||||
`, runID, stageName, ordinal)
|
||||
ss, err := scanSubStep(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return ss, err
|
||||
}
|
||||
|
||||
// ListForRun returns every sub-step for a run, ordered by (stage_name,
|
||||
// ordinal). The caller groups by StageName to build the UI.
|
||||
func (s *SubSteps) ListForRun(ctx context.Context, runID int64) ([]model.SubStep, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `
|
||||
SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
|
||||
FROM sub_steps WHERE run_id = ? ORDER BY stage_name, ordinal
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.SubStep
|
||||
for rows.Next() {
|
||||
ss, err := scanSubStep(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *ss)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListForStage returns every sub-step for one stage of a run, in ordinal
|
||||
// order. Convenience over filtering ListForRun client-side.
|
||||
func (s *SubSteps) ListForStage(ctx context.Context, runID int64, stageName string) ([]model.SubStep, error) {
|
||||
rows, err := s.DB.QueryContext(ctx, `
|
||||
SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
|
||||
FROM sub_steps WHERE run_id = ? AND stage_name = ? ORDER BY ordinal
|
||||
`, runID, stageName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.SubStep
|
||||
for rows.Next() {
|
||||
ss, err := scanSubStep(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *ss)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanSubStep(r scanner) (*model.SubStep, error) {
|
||||
var ss model.SubStep
|
||||
var started, completed sql.NullTime
|
||||
if err := r.Scan(&ss.ID, &ss.RunID, &ss.StageName, &ss.Ordinal, &ss.Name,
|
||||
&ss.State, &started, &completed, &ss.SummaryJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if started.Valid {
|
||||
t := started.Time
|
||||
ss.StartedAt = &t
|
||||
}
|
||||
if completed.Valid {
|
||||
t := completed.Time
|
||||
ss.CompletedAt = &t
|
||||
}
|
||||
return &ss, nil
|
||||
}
|
||||
|
||||
func nullTime(t *time.Time) any {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
func jsonOrDefault(s string) string {
|
||||
if s == "" {
|
||||
return "{}"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// TestSubStepsUpsertAndList covers the two-phase life cycle the agent
|
||||
// is meant to exercise: an initial "started" upsert with state=running
|
||||
// and started_at set, then a terminal upsert that flips state to passed
|
||||
// and fills completed_at without clobbering started_at. Ordering falls
|
||||
// out of the (stage_name, ordinal) index — ListForRun must return rows
|
||||
// in that deterministic order even when they were inserted out of order.
|
||||
func TestSubStepsUpsertAndList(t *testing.T) {
|
||||
runs := newDB(t)
|
||||
_, runID := seedRun(t, runs)
|
||||
ss := &store.SubSteps{DB: runs.DB}
|
||||
ctx := context.Background()
|
||||
|
||||
start := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC)
|
||||
end := start.Add(3 * time.Minute)
|
||||
|
||||
// Insert CPUStress ordinals out of order to prove ListForRun orders
|
||||
// by (stage, ordinal) rather than insertion time.
|
||||
if err := ss.Upsert(ctx, model.SubStep{
|
||||
RunID: runID, StageName: "CPUStress", Ordinal: 1,
|
||||
Name: "Memory pass", State: model.StageRunning, StartedAt: &start,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert CPUStress/1 running: %v", err)
|
||||
}
|
||||
if err := ss.Upsert(ctx, model.SubStep{
|
||||
RunID: runID, StageName: "CPUStress", Ordinal: 0,
|
||||
Name: "CPU pass", State: model.StagePassed, StartedAt: &start, CompletedAt: &end,
|
||||
SummaryJSON: `{"elapsed_secs":180}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert CPUStress/0 passed: %v", err)
|
||||
}
|
||||
// Terminal update for ordinal 1 — only completed_at + state; started_at
|
||||
// intentionally omitted so COALESCE preserves the original value.
|
||||
if err := ss.Upsert(ctx, model.SubStep{
|
||||
RunID: runID, StageName: "CPUStress", Ordinal: 1,
|
||||
Name: "Memory pass", State: model.StagePassed, CompletedAt: &end,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert CPUStress/1 passed: %v", err)
|
||||
}
|
||||
|
||||
list, err := ss.ListForRun(ctx, runID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForRun: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(list))
|
||||
}
|
||||
if list[0].Name != "CPU pass" || list[1].Name != "Memory pass" {
|
||||
t.Fatalf("order: %+v", list)
|
||||
}
|
||||
if list[1].State != model.StagePassed {
|
||||
t.Fatalf("memory pass state = %q, want passed", list[1].State)
|
||||
}
|
||||
if list[1].StartedAt == nil {
|
||||
t.Fatalf("memory pass started_at was wiped by terminal upsert")
|
||||
}
|
||||
if !list[1].StartedAt.Equal(start) {
|
||||
t.Fatalf("started_at = %v, want %v", list[1].StartedAt, start)
|
||||
}
|
||||
if list[0].SummaryJSON != `{"elapsed_secs":180}` {
|
||||
t.Fatalf("CPU pass summary = %q, want {\"elapsed_secs\":180}", list[0].SummaryJSON)
|
||||
}
|
||||
|
||||
// ListForStage scopes to a single stage.
|
||||
only, err := ss.ListForStage(ctx, runID, "CPUStress")
|
||||
if err != nil {
|
||||
t.Fatalf("ListForStage: %v", err)
|
||||
}
|
||||
if len(only) != 2 {
|
||||
t.Fatalf("ListForStage CPUStress: got %d, want 2", len(only))
|
||||
}
|
||||
other, err := ss.ListForStage(ctx, runID, "Storage")
|
||||
if err != nil {
|
||||
t.Fatalf("ListForStage Storage: %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Fatalf("ListForStage Storage should be empty, got %d", len(other))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubStepsSummaryPreserveOnDefault verifies the intentional special-
|
||||
// case: when the second upsert supplies the default empty-object summary
|
||||
// ('{}'), the prior non-default summary is kept. This lets a "complete"
|
||||
// call that only carries timing update the row without wiping the rich
|
||||
// summary the "start" call persisted.
|
||||
func TestSubStepsSummaryPreserveOnDefault(t *testing.T) {
|
||||
runs := newDB(t)
|
||||
_, runID := seedRun(t, runs)
|
||||
ss := &store.SubSteps{DB: runs.DB}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := ss.Upsert(ctx, model.SubStep{
|
||||
RunID: runID, StageName: "SMART", Ordinal: 0,
|
||||
Name: "smartctl /dev/sda", State: model.StageRunning,
|
||||
SummaryJSON: `{"device":"/dev/sda"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("first upsert: %v", err)
|
||||
}
|
||||
// Terminal with empty summary — should not blow away the device field.
|
||||
if err := ss.Upsert(ctx, model.SubStep{
|
||||
RunID: runID, StageName: "SMART", Ordinal: 0,
|
||||
Name: "smartctl /dev/sda", State: model.StagePassed,
|
||||
}); err != nil {
|
||||
t.Fatalf("terminal upsert: %v", err)
|
||||
}
|
||||
got, err := ss.Get(ctx, runID, "SMART", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.SummaryJSON != `{"device":"/dev/sda"}` {
|
||||
t.Fatalf("summary wiped: %q", got.SummaryJSON)
|
||||
}
|
||||
if got.State != model.StagePassed {
|
||||
t.Fatalf("state = %q, want passed", got.State)
|
||||
}
|
||||
}
|
||||
+408
-15
@@ -202,8 +202,14 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
||||
.tile-pass { border-color: rgba(53,194,123,.5); }
|
||||
.tile-active { border-color: var(--accent); }
|
||||
|
||||
.form-wrap { max-width: 640px; }
|
||||
.form-wrap h1 { font-size: 20px; }
|
||||
.form-wrap { max-width: 640px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.form-wrap h1 { font-size: 20px; margin: 0; }
|
||||
.form-wrap-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.host-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.host-form label { display: flex; flex-direction: column; gap: 4px; color: var(--text-dim); font-size: 13px; }
|
||||
@@ -245,14 +251,10 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
||||
|
||||
body.bare main { max-width: none; }
|
||||
|
||||
.quick-register {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.quick-register h2 { margin: 0 0 8px; font-size: 16px; }
|
||||
/* .quick-register now inherits card shell from .detail-section; these
|
||||
rules only cover its own content. */
|
||||
.quick-register h2 { margin: 0 0 8px; font-size: 15px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); font-weight: 600; }
|
||||
.quick-register h2 .muted { text-transform: none; letter-spacing: 0; }
|
||||
.quick-register p { margin: 6px 0; font-size: 13px; color: var(--text-dim); }
|
||||
.quick-register p b { color: var(--text); }
|
||||
.quick-register .muted { color: var(--text-dim); font-weight: 400; }
|
||||
@@ -270,14 +272,31 @@ body.bare main { max-width: none; }
|
||||
}
|
||||
.quick-register .one-liner code { white-space: pre; }
|
||||
|
||||
.manual-register { margin-top: 16px; }
|
||||
.manual-register-card { padding-top: 10px; padding-bottom: 14px; }
|
||||
.manual-register summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
padding: 6px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.manual-register summary:hover { color: var(--text); }
|
||||
.manual-register summary::before {
|
||||
content: "▸";
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
transition: transform .1s ease;
|
||||
}
|
||||
.manual-register[open] > summary::before { transform: rotate(90deg); }
|
||||
.manual-register summary h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.manual-register summary:hover h2 { color: var(--text); }
|
||||
.manual-register[open] summary { margin-bottom: 12px; }
|
||||
|
||||
/* ===== Host detail page ===== */
|
||||
@@ -549,3 +568,377 @@ body.bare main { max-width: none; }
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(60,130,246,0); }
|
||||
}
|
||||
|
||||
/* ===== Host detail v2 — GitHub-Actions-style layout ===== */
|
||||
|
||||
.detail-v2 { gap: 12px; }
|
||||
|
||||
.host-meta-drawer {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.host-meta-drawer > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.host-meta-drawer > summary::before {
|
||||
content: "▸";
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
transition: transform .1s ease;
|
||||
}
|
||||
.host-meta-drawer[open] > summary::before { transform: rotate(90deg); }
|
||||
.host-meta-drawer .meta-summary-label { color: var(--text); font-weight: 600; }
|
||||
.host-meta-drawer .meta-summary-mac { font-family: var(--mono); margin-left: auto; }
|
||||
.host-meta-drawer[open] > summary { margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
|
||||
|
||||
.run-header {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.run-header.tile-fail { border-color: rgba(229,100,102,.6); }
|
||||
.run-header.tile-pass { border-color: rgba(53,194,123,.5); }
|
||||
.run-header.tile-active { border-color: var(--accent); }
|
||||
.run-header-left { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
|
||||
.run-header-right { display: flex; align-items: center; gap: 14px; font-size: 13px; }
|
||||
.run-header .detail-name { margin: 0; font-size: 22px; }
|
||||
.run-number { font-family: var(--mono); font-size: 15px; color: var(--text-dim); }
|
||||
.run-status-badge {
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.run-status-pass { background: rgba(53,194,123,.15); border-color: rgba(53,194,123,.5); color: var(--success); }
|
||||
.run-status-fail { background: rgba(229,100,102,.12); border-color: rgba(229,100,102,.5); color: var(--danger); }
|
||||
.run-status-active { background: rgba(60,130,246,.15); border-color: rgba(60,130,246,.5); color: var(--accent); }
|
||||
.run-duration { font-family: var(--mono); font-size: 13px; color: var(--text-dim); }
|
||||
.run-failed-stage { color: var(--danger); }
|
||||
.run-failed-stage strong { font-family: var(--mono); }
|
||||
.run-diffs { color: var(--danger); }
|
||||
|
||||
.hold-banner {
|
||||
background: rgba(229,100,102,.1);
|
||||
border: 1px solid rgba(229,100,102,.5);
|
||||
border-left: 4px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hold-banner-label { color: var(--danger); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.hold-banner .hold-ssh {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
user-select: all;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0,0,0,.3);
|
||||
border: 1px solid rgba(229,100,102,.3);
|
||||
border-radius: 4px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.detail-hold-placeholder { display: none; }
|
||||
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 260px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.detail-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
.active-step-pane { display: flex; flex-direction: column; gap: 8px; }
|
||||
.detail-empty {
|
||||
padding: 24px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.step[open] { border-color: var(--accent); }
|
||||
.step-passed { border-left: 3px solid var(--success); }
|
||||
.step-running { border-left: 3px solid var(--accent); }
|
||||
.step-failed { border-left: 3px solid var(--danger); }
|
||||
.step-skipped { opacity: .55; }
|
||||
.step-pending { opacity: .7; }
|
||||
.step > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.step > summary::-webkit-details-marker { display: none; }
|
||||
.step-summary .stage-dot { width: 20px; height: 20px; font-size: 12px; flex-shrink: 0; }
|
||||
.step-name { font-weight: 600; color: var(--text); flex: 1; }
|
||||
.step-duration { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||
.step-body {
|
||||
padding: 8px 14px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.substep-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.substep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-elev);
|
||||
font-size: 13px;
|
||||
}
|
||||
.substep-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.substep-badge-passed { background: var(--success); border-color: var(--success); color: #0b0d12; }
|
||||
.substep-badge-running { background: var(--accent-strong); border-color: var(--accent); color: #fff; animation: pulse 1.2s ease-in-out infinite; }
|
||||
.substep-badge-failed { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.substep-badge-skipped { opacity: .5; }
|
||||
.substep-name { flex: 1; }
|
||||
.substep-duration { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||
.substep-failed { border-color: rgba(229,100,102,.5); }
|
||||
.substep-running { border-color: rgba(60,130,246,.5); }
|
||||
|
||||
.log-search-wrap { display: flex; }
|
||||
.log-search {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
.log-search::placeholder { color: var(--text-dim); }
|
||||
.log-search:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* Log pane: now a standalone block (no tabs). Each step owns its pane. */
|
||||
.step .log-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 6px 0;
|
||||
background: #0b0d12;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
order: unset;
|
||||
}
|
||||
.step .log-pane:empty::before {
|
||||
content: "(no log output for this step yet)";
|
||||
padding: 10px 12px;
|
||||
color: var(--text-dim);
|
||||
opacity: .5;
|
||||
}
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 48px 56px 72px auto;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 2px 10px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.log-line:target { background: rgba(60,130,246,.12); border-left-color: var(--accent); }
|
||||
.log-line .log-anchor {
|
||||
color: var(--text-dim);
|
||||
opacity: 0;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
.log-line:hover .log-anchor { opacity: 1; }
|
||||
.log-line .ln { color: var(--text-dim); opacity: .6; text-align: right; user-select: none; }
|
||||
.log-line .lvl { color: var(--text-dim); text-transform: uppercase; font-size: 10px; font-weight: 700; letter-spacing: .5px; }
|
||||
.log-line .log-ts { color: var(--text-dim); opacity: .75; }
|
||||
.log-line .log-stage { color: var(--text-dim); opacity: .75; margin-right: 4px; }
|
||||
.log-line .log-text { color: var(--text); white-space: pre-wrap; word-break: break-word; }
|
||||
.log-line.log-warn .log-text { color: var(--warn); }
|
||||
.log-line.log-warn .lvl { color: var(--warn); }
|
||||
.log-line.log-error .log-text { color: var(--danger); }
|
||||
.log-line.log-error .lvl { color: var(--danger); }
|
||||
.log-line.log-debug { opacity: .6; }
|
||||
.log-line.log-hit { background: rgba(228,169,75,.08); }
|
||||
|
||||
.runs-sidebar {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.runs-sidebar-heading {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.runs-sidebar-empty { color: var(--text-dim); font-size: 13px; margin: 0; }
|
||||
.runs-sidebar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.runs-sidebar-item a {
|
||||
display: grid;
|
||||
grid-template-columns: 16px auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
.runs-sidebar-item a:hover { background: var(--bg-elev-2); text-decoration: none; }
|
||||
.runs-sidebar-active a { background: rgba(60,130,246,.12); border: 1px solid rgba(60,130,246,.5); }
|
||||
.runs-sidebar-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.runs-sidebar-dot-pass { background: var(--success); border-color: var(--success); color: #0b0d12; }
|
||||
.runs-sidebar-dot-fail { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.runs-sidebar-dot-active { background: var(--accent-strong); border-color: var(--accent); color: #fff; }
|
||||
.runs-sidebar-id { font-family: var(--mono); font-weight: 600; }
|
||||
.runs-sidebar-started { color: var(--text-dim); }
|
||||
.runs-sidebar-duration { font-family: var(--mono); color: var(--text-dim); font-size: 11px; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent); border-color: var(--accent); }
|
||||
.btn-danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
background: transparent;
|
||||
}
|
||||
.btn-danger:hover { background: rgba(229,100,102,.1); }
|
||||
|
||||
/* ---------- Dashboard tile mini run-view (Phase 3) ---------------- */
|
||||
|
||||
/* Small variant of stage-dot for the compact step list. Same colour
|
||||
rules as the full-size pipeline dot so operators read one language
|
||||
everywhere; only the geometry shrinks. */
|
||||
.stage-dot-sm {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 9px;
|
||||
border-width: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tile-meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 0 6px;
|
||||
}
|
||||
.tile-run-id { font-variant-numeric: tabular-nums; }
|
||||
.tile-run-duration { margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.tile-steplist {
|
||||
list-style: none;
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2px 10px;
|
||||
}
|
||||
.tile-steplist .tile-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-dim);
|
||||
min-width: 0;
|
||||
}
|
||||
.tile-steplist .tile-step-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Passed/failed/running steps keep full-strength text so the eye jumps
|
||||
to active work; pending/skipped fade back into the background. */
|
||||
.tile-step-passed .tile-step-name,
|
||||
.tile-step-failed .tile-step-name,
|
||||
.tile-step-running .tile-step-name { color: var(--text); }
|
||||
.tile-step-skipped { opacity: .5; }
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Detail-page client behaviors. Loaded in layout.templ with `defer` so the
|
||||
// DOM is parsed before any listeners fire. Three jobs:
|
||||
//
|
||||
// 1. Auto-advance: when a substep-* SSE event lands with state=running,
|
||||
// open the parent step panel and collapse any previously-running step
|
||||
// that's now completed. Keeps the operator's attention on the thing
|
||||
// that's currently moving without manual clicks.
|
||||
// 2. In-step search: filter `.log-line` rows inside the current step by
|
||||
// substring match. Client-side only — the log pane's `<details>` ancestor
|
||||
// scopes the filter naturally.
|
||||
// 3. Permalink scroll + highlight: when the URL carries `#L{run}-{stage}-{ord}`
|
||||
// on load, scroll that log line into view; anchor clicks update
|
||||
// `location.hash` without a reload.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// --- 1. auto-advance on substep SSE ---------------------------------
|
||||
|
||||
document.body.addEventListener('htmx:sseMessage', function (ev) {
|
||||
var name = ev.detail && ev.detail.type;
|
||||
if (!name || name.indexOf('substep-') !== 0) {
|
||||
return;
|
||||
}
|
||||
// After htmx has applied the swap, check which step the just-updated
|
||||
// substep belongs to. We scan *after* the swap so we see the new
|
||||
// class ("substep-running" / "substep-passed") rather than the old.
|
||||
setTimeout(function () {
|
||||
autoAdvance();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
function autoAdvance() {
|
||||
var steps = document.querySelectorAll('.step[data-stage]');
|
||||
var runningStep = null;
|
||||
steps.forEach(function (step) {
|
||||
if (step.querySelector('.substep-running')) {
|
||||
runningStep = step;
|
||||
}
|
||||
});
|
||||
if (!runningStep) {
|
||||
return;
|
||||
}
|
||||
// Open the running step; collapse any other open step that no longer
|
||||
// has a running substep. The default-open step picked server-side
|
||||
// stays open if nothing is running yet.
|
||||
steps.forEach(function (step) {
|
||||
if (step === runningStep) {
|
||||
if (!step.open) { step.open = true; }
|
||||
return;
|
||||
}
|
||||
if (step.open && !step.querySelector('.substep-running')) {
|
||||
// Leave the "currently-failed" step open even when we
|
||||
// auto-advance — operator still wants to see what broke.
|
||||
if (step.classList.contains('step-failed')) { return; }
|
||||
step.open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2. in-step search ----------------------------------------------
|
||||
|
||||
document.body.addEventListener('input', function (ev) {
|
||||
var el = ev.target;
|
||||
if (!el.classList || !el.classList.contains('log-search')) {
|
||||
return;
|
||||
}
|
||||
var step = el.closest('.step');
|
||||
if (!step) { return; }
|
||||
var query = el.value.trim().toLowerCase();
|
||||
step.querySelectorAll('.log-line').forEach(function (line) {
|
||||
if (!query) {
|
||||
line.style.display = '';
|
||||
line.classList.remove('log-hit');
|
||||
return;
|
||||
}
|
||||
var text = (line.textContent || '').toLowerCase();
|
||||
if (text.indexOf(query) === -1) {
|
||||
line.style.display = 'none';
|
||||
line.classList.remove('log-hit');
|
||||
} else {
|
||||
line.style.display = '';
|
||||
line.classList.add('log-hit');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- 3. permalink scroll + highlight on load ------------------------
|
||||
|
||||
function scrollToHash() {
|
||||
var hash = (location.hash || '').replace(/^#/, '');
|
||||
if (!hash) { return; }
|
||||
var target = document.getElementById(hash);
|
||||
if (!target) { return; }
|
||||
// Open the enclosing step so the target is actually visible.
|
||||
var step = target.closest('.step');
|
||||
if (step && !step.open) { step.open = true; }
|
||||
target.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
|
||||
window.addEventListener('load', scrollToHash);
|
||||
window.addEventListener('hashchange', scrollToHash);
|
||||
|
||||
// Anchor clicks update location.hash without triggering navigation;
|
||||
// the hashchange listener above handles the scroll + highlight.
|
||||
document.body.addEventListener('click', function (ev) {
|
||||
var a = ev.target.closest && ev.target.closest('.log-anchor');
|
||||
if (!a) { return; }
|
||||
ev.preventDefault();
|
||||
var href = a.getAttribute('href') || '';
|
||||
if (href.indexOf('#') === 0) {
|
||||
history.replaceState(null, '', href);
|
||||
scrollToHash();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// ActiveStepData is the per-stage payload for the expanded step panel.
|
||||
// The handler builds one per stage in DefaultStageOrder and hands it to
|
||||
// ActiveStep so the template stays free of any slicing logic.
|
||||
type ActiveStepData struct {
|
||||
RunID int64
|
||||
Stage model.Stage
|
||||
SubSteps []model.SubStep
|
||||
LogReplay string
|
||||
Open bool
|
||||
}
|
||||
|
||||
// ActiveStep renders one stage's expanded panel: the header summary
|
||||
// (state badge, stage name, duration), any sub-step rows, a per-step
|
||||
// search box, and a live log pane scoped to that stage's SSE topic.
|
||||
// Uses <details open?={ d.Open }> so the server-picked default stage
|
||||
// opens automatically on page load; app.js takes over after that for
|
||||
// SSE-driven auto-advance.
|
||||
templ ActiveStep(d ActiveStepData) {
|
||||
<details class={ "step", "step-" + string(d.Stage.State) } open?={ d.Open } data-stage={ d.Stage.Name }>
|
||||
<summary class="step-summary">
|
||||
<span class={ "stage-dot", "stage-dot-" + string(d.Stage.State) }>{ stageMarker(string(d.Stage.State)) }</span>
|
||||
<span class="step-name">{ d.Stage.Name }</span>
|
||||
<span class="step-duration">{ stageDurationFromStage(d.Stage) }</span>
|
||||
</summary>
|
||||
<div class="step-body">
|
||||
if len(d.SubSteps) > 0 {
|
||||
<ol class="substep-list">
|
||||
for _, ss := range d.SubSteps {
|
||||
@SubStepRow(ss)
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
<div class="log-search-wrap">
|
||||
<input class="log-search" type="search" placeholder="Search this step" data-step={ d.Stage.Name }/>
|
||||
</div>
|
||||
<div
|
||||
class="log-pane"
|
||||
id={ fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name) }
|
||||
sse-swap={ fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name) }
|
||||
hx-swap="beforeend show:bottom"
|
||||
>
|
||||
@templ.Raw(d.LogReplay)
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
// SubStepsForStage filters a flat []SubStep to just the entries for one
|
||||
// stage. Used by host_detail when wiring ActiveStepData — keeps the
|
||||
// filtering logic testable and off the template surface.
|
||||
func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
|
||||
out := make([]model.SubStep, 0, len(all))
|
||||
for _, ss := range all {
|
||||
if ss.StageName == stageName {
|
||||
out = append(out, ss)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
|
||||
// formatting rules, different input shape.
|
||||
func stageDurationFromStage(s model.Stage) string {
|
||||
if s.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if s.CompletedAt != nil {
|
||||
end = *s.CompletedAt
|
||||
}
|
||||
d := end.Sub(*s.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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// ActiveStepData is the per-stage payload for the expanded step panel.
|
||||
// The handler builds one per stage in DefaultStageOrder and hands it to
|
||||
// ActiveStep so the template stays free of any slicing logic.
|
||||
type ActiveStepData struct {
|
||||
RunID int64
|
||||
Stage model.Stage
|
||||
SubSteps []model.SubStep
|
||||
LogReplay string
|
||||
Open bool
|
||||
}
|
||||
|
||||
// ActiveStep renders one stage's expanded panel: the header summary
|
||||
// (state badge, stage name, duration), any sub-step rows, a per-step
|
||||
// search box, and a live log pane scoped to that stage's SSE topic.
|
||||
// Uses <details open?={ d.Open }> so the server-picked default stage
|
||||
// opens automatically on page load; app.js takes over after that for
|
||||
// SSE-driven auto-advance.
|
||||
func ActiveStep(d ActiveStepData) 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)
|
||||
var templ_7745c5c3_Var2 = []any{"step", "step-" + string(d.Stage.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, 1, "<details 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/active_step.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, 2, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Open {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " open")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " data-stage=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 28, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"><summary class=\"step-summary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 = []any{"stage-dot", "stage-dot-" + string(d.Stage.State)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
|
||||
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_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 7, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(d.Stage.State)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 30, Col: 105}
|
||||
}
|
||||
_, 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, "</span> <span class=\"step-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 31, Col: 41}
|
||||
}
|
||||
_, 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, "</span> <span class=\"step-duration\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDurationFromStage(d.Stage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 32, Col: 64}
|
||||
}
|
||||
_, 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, "</span></summary><div class=\"step-body\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.SubSteps) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<ol class=\"substep-list\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, ss := range d.SubSteps {
|
||||
templ_7745c5c3_Err = SubStepRow(ss).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</ol>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"log-search-wrap\"><input class=\"log-search\" type=\"search\" placeholder=\"Search this step\" data-step=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 43, Col: 99}
|
||||
}
|
||||
_, 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, 14, "\"></div><div class=\"log-pane\" id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 56}
|
||||
}
|
||||
_, 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, 15, "\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 48, Col: 62}
|
||||
}
|
||||
_, 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, 16, "\" hx-swap=\"beforeend show:bottom\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templ.Raw(d.LogReplay).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div></details>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SubStepsForStage filters a flat []SubStep to just the entries for one
|
||||
// stage. Used by host_detail when wiring ActiveStepData — keeps the
|
||||
// filtering logic testable and off the template surface.
|
||||
func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
|
||||
out := make([]model.SubStep, 0, len(all))
|
||||
for _, ss := range all {
|
||||
if ss.StageName == stageName {
|
||||
out = append(out, ss)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
|
||||
// formatting rules, different input shape.
|
||||
func stageDurationFromStage(s model.Stage) string {
|
||||
if s.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if s.CompletedAt != nil {
|
||||
end = *s.CompletedAt
|
||||
}
|
||||
d := end.Sub(*s.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))
|
||||
}
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -9,10 +9,14 @@ import (
|
||||
// TileData pairs a host with its latest run and the derived fields the
|
||||
// tile needs to render: spec-diff count (server-side diff result) and
|
||||
// the on-disk path to the hold-key artifact when the run is holding.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is
|
||||
// the list of canonical-order stage rows for Latest, used by HostTile
|
||||
// to render the mini run-view; nil/empty for never-run hosts (a ghost
|
||||
// dot strip is rendered from DefaultStageOrder).
|
||||
type TileData struct {
|
||||
Host model.Host
|
||||
Latest *model.Run
|
||||
Stages []model.Stage
|
||||
SpecDiffCritical int
|
||||
HoldKeyPath string
|
||||
LastSeenAt *time.Time
|
||||
|
||||
@@ -17,10 +17,14 @@ import (
|
||||
// TileData pairs a host with its latest run and the derived fields the
|
||||
// tile needs to render: spec-diff count (server-side diff result) and
|
||||
// the on-disk path to the hold-key artifact when the run is holding.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat.
|
||||
// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is
|
||||
// the list of canonical-order stage rows for Latest, used by HostTile
|
||||
// to render the mini run-view; nil/empty for never-run hosts (a ghost
|
||||
// dot strip is rendered from DefaultStageOrder).
|
||||
type TileData struct {
|
||||
Host model.Host
|
||||
Latest *model.Run
|
||||
Stages []model.Stage
|
||||
SpecDiffCritical int
|
||||
HoldKeyPath string
|
||||
LastSeenAt *time.Time
|
||||
|
||||
@@ -4,34 +4,56 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// HostDetailData is the full payload the detail handler hands to the
|
||||
// HostDetail template. Tile carries host + latest-run enrichment (same
|
||||
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
||||
// and diff list. LogReplay is the pre-rendered history fragment
|
||||
// produced by logs.Hub.Replay on the initial page render so the operator
|
||||
// sees prior output without waiting for a fresh SSE event.
|
||||
// HostDetail template. Tile carries host + viewed-run enrichment (same
|
||||
// shape the dashboard tile uses), Stages/SpecDiffs/SubSteps drive the
|
||||
// pipeline, diff list, and expanded step panel. History backs the runs
|
||||
// sidebar (last 20, newest first). DefaultStepStage is the stage name
|
||||
// whose <details> opens by default on page load — running → failed →
|
||||
// Reporting. LogReplay is the pre-rendered history fragment produced
|
||||
// by logs.Hub.Replay on the initial page render so the operator sees
|
||||
// prior output without waiting for a fresh SSE event.
|
||||
type HostDetailData struct {
|
||||
Tile TileData
|
||||
Stages []model.Stage
|
||||
SpecDiffs []model.SpecDiff
|
||||
LogReplay string
|
||||
Tile TileData
|
||||
Stages []model.Stage
|
||||
SpecDiffs []model.SpecDiff
|
||||
SubSteps []model.SubStep
|
||||
History []model.Run
|
||||
DefaultStepStage string
|
||||
LogReplay string
|
||||
// LogReplayByStage is the pre-rendered log HTML grouped by stage
|
||||
// name. Each ActiveStep panel picks its own bucket so the detail
|
||||
// page doesn't fire nine disk scans per reload. The "" key holds
|
||||
// orphan/framing lines (no stage set), surfaced under the "Run"
|
||||
// pseudo-step at the top of the page.
|
||||
LogReplayByStage map[string]string
|
||||
}
|
||||
|
||||
// HostDetail is the GitHub-Actions-style run view. Layout is: meta
|
||||
// drawer (collapsed) → run header + actions → hold banner → horizontal
|
||||
// pipeline → two-column body (active-step pane + runs sidebar) → spec
|
||||
// diffs at the bottom. Each section keeps its own sse-swap target so
|
||||
// live updates don't trigger whole-page reflows.
|
||||
templ HostDetail(d HostDetailData) {
|
||||
@Layout(d.Tile.Host.Name) {
|
||||
<section class="detail" hx-ext="sse" sse-connect="/events">
|
||||
<section class="detail detail-v2" hx-ext="sse" sse-connect="/events">
|
||||
<nav class="breadcrumb">
|
||||
<a href="/">Dashboard</a>
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<span>{ d.Tile.Host.Name }</span>
|
||||
</nav>
|
||||
|
||||
@HostMetaDrawer(d)
|
||||
|
||||
@DetailSummary(d)
|
||||
@DetailActions(d)
|
||||
@DetailHold(d)
|
||||
|
||||
if d.Tile.Latest != nil {
|
||||
@PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages))
|
||||
@@ -42,51 +64,41 @@ templ HostDetail(d HostDetailData) {
|
||||
</section>
|
||||
}
|
||||
|
||||
@DetailHold(d)
|
||||
@DetailActions(d)
|
||||
@DetailSpecDiffs(d)
|
||||
|
||||
if d.Tile.Latest != nil {
|
||||
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
|
||||
}
|
||||
|
||||
<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-body">
|
||||
<div class="active-step-pane">
|
||||
if d.Tile.Latest != nil {
|
||||
for _, stageName := range store.DefaultStageOrder {
|
||||
@ActiveStep(ActiveStepData{
|
||||
RunID: d.Tile.Latest.ID,
|
||||
Stage: stageForName(d.Stages, stageName),
|
||||
SubSteps: SubStepsForStage(d.SubSteps, stageName),
|
||||
LogReplay: d.LogReplayByStage[stageName],
|
||||
Open: stageName == d.DefaultStepStage,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
<p class="detail-empty">No run yet. Click <strong>Start vetting</strong> to begin.</p>
|
||||
}
|
||||
<div class="detail-spec">
|
||||
<h3>Expected spec</h3>
|
||||
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
@RunsSidebar(d)
|
||||
</div>
|
||||
|
||||
@DetailSpecDiffs(d)
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
// DetailSummary is the status header at the top of the detail page:
|
||||
// name, last-seen badge, run status, MAC/WoL/failed-stage/spec-diffs
|
||||
// meta grid. Keyed on host ID so the SSE event name is stable across
|
||||
// run turnover.
|
||||
templ DetailSummary(d HostDetailData) {
|
||||
<header
|
||||
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }
|
||||
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<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>
|
||||
// HostMetaDrawer is the collapsed "host details" block at the top of the
|
||||
// page: MAC, WoL, last-seen, expected spec, and notes. <details> defaults
|
||||
// to closed so the run itself stays above the fold; operators open it
|
||||
// when they need the provisioning info.
|
||||
templ HostMetaDrawer(d HostDetailData) {
|
||||
<details class="host-meta-drawer">
|
||||
<summary>
|
||||
<span class="meta-summary-label">Host details</span>
|
||||
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
|
||||
<span class="meta-summary-mac">{ d.Tile.Host.MAC }</span>
|
||||
</summary>
|
||||
<dl class="detail-meta">
|
||||
<div>
|
||||
<dt>MAC</dt>
|
||||
@@ -96,19 +108,48 @@ templ DetailSummary(d HostDetailData) {
|
||||
<dt>WoL</dt>
|
||||
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
|
||||
</div>
|
||||
</dl>
|
||||
if d.Tile.Host.Notes != "" {
|
||||
<div class="detail-notes">
|
||||
<h3>Notes</h3>
|
||||
<p>{ d.Tile.Host.Notes }</p>
|
||||
</div>
|
||||
}
|
||||
<div class="detail-spec">
|
||||
<h3>Expected spec</h3>
|
||||
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
// DetailSummary is the run header: host name on the left, run number,
|
||||
// status icon, and elapsed/total duration. Keyed on host ID so the SSE
|
||||
// event name is stable across run turnover.
|
||||
templ DetailSummary(d HostDetailData) {
|
||||
<header
|
||||
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
class={ "run-header", "tile-" + tileMood(d.Tile.Latest) }
|
||||
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="run-header-left">
|
||||
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
||||
if d.Tile.Latest != nil {
|
||||
<span class="run-number">{ fmt.Sprintf("run #%d", d.Tile.Latest.ID) }</span>
|
||||
}
|
||||
<span class={ "run-status-badge", "run-status-" + tileMood(d.Tile.Latest) }>{ tileStatus(d.Tile.Latest) }</span>
|
||||
if d.Tile.Latest != nil {
|
||||
<span class="run-duration">{ runDuration(d.Tile.Latest) }</span>
|
||||
}
|
||||
</div>
|
||||
<div class="run-header-right">
|
||||
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
|
||||
<div>
|
||||
<dt>Failed at</dt>
|
||||
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
|
||||
</div>
|
||||
<span class="run-failed-stage">failed at <strong>{ d.Tile.Latest.FailedStage }</strong></span>
|
||||
}
|
||||
if d.Tile.SpecDiffCritical > 0 {
|
||||
<div>
|
||||
<dt>Spec diffs</dt>
|
||||
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
|
||||
</div>
|
||||
<span class="run-diffs bad">{ fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical) }</span>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
@@ -124,7 +165,6 @@ templ DetailActions(d HostDetailData) {
|
||||
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<h2>Actions</h2>
|
||||
<div class="detail-actions-row">
|
||||
if canStart(d.Tile) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
|
||||
@@ -132,7 +172,7 @@ templ DetailActions(d HostDetailData) {
|
||||
<input type="checkbox" name="non_destructive" value="1"/>
|
||||
Non-destructive (skip wipe-probe + disk writes)
|
||||
</label>
|
||||
<button type="submit">Start vetting</button>
|
||||
<button type="submit" class="btn-primary">Start vetting</button>
|
||||
</form>
|
||||
} else if canStartIfOnline(d.Tile.Latest) {
|
||||
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
|
||||
@@ -141,19 +181,19 @@ templ DetailActions(d HostDetailData) {
|
||||
}
|
||||
if canCancel(d.Tile.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
|
||||
<button type="submit" class="danger">Cancel run</button>
|
||||
<button type="submit" class="btn-danger">Cancel run</button>
|
||||
</form>
|
||||
}
|
||||
if canOverrideWipe(d.Tile.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Override wipe-probe</button>
|
||||
<button type="submit" class="btn-danger">Override wipe-probe</button>
|
||||
</form>
|
||||
}
|
||||
if hasReport(d.Tile.Latest) {
|
||||
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
||||
}
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Delete host</button>
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Delete host and all its runs?');">
|
||||
<button type="submit" class="btn-danger">Delete host</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -192,20 +232,21 @@ templ DetailSpecDiffs(d HostDetailData) {
|
||||
}
|
||||
}
|
||||
|
||||
// DetailHold renders the "Host is holding — SSH available" block while
|
||||
// a run is in FailedHolding with an IP recorded. Otherwise it emits an
|
||||
// empty wrapper so the first push when the hold actually fires has a
|
||||
// target. Keyed on run ID for the same reason as DetailSpecDiffs.
|
||||
// DetailHold renders the "Host is holding — SSH available" strip across
|
||||
// the top when a run is in FailedHolding with an IP recorded. Otherwise
|
||||
// it emits an empty wrapper so the first SSE push when the hold actually
|
||||
// fires has a target. Keyed on run ID for the same reason as
|
||||
// DetailSpecDiffs.
|
||||
templ DetailHold(d HostDetailData) {
|
||||
if d.Tile.Latest != nil {
|
||||
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
||||
<section
|
||||
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||
class="detail-section detail-hold"
|
||||
class="hold-banner"
|
||||
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<h2>Host is holding — SSH available</h2>
|
||||
<span class="hold-banner-label">Host is holding — SSH available:</span>
|
||||
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
||||
</section>
|
||||
} else {
|
||||
@@ -219,6 +260,32 @@ templ DetailHold(d HostDetailData) {
|
||||
}
|
||||
}
|
||||
|
||||
// RunsSidebar is the right-rail history list: last 20 runs for this
|
||||
// host, newest first. Each entry links back to /hosts/{id}?run=N for
|
||||
// navigation into a past run. The row for the currently-viewed run is
|
||||
// flagged so CSS can highlight it.
|
||||
templ RunsSidebar(d HostDetailData) {
|
||||
<aside class="runs-sidebar">
|
||||
<h2 class="runs-sidebar-heading">History</h2>
|
||||
if len(d.History) == 0 {
|
||||
<p class="runs-sidebar-empty">No runs yet.</p>
|
||||
} else {
|
||||
<ul class="runs-sidebar-list">
|
||||
for _, r := range d.History {
|
||||
<li class={ "runs-sidebar-item", "runs-sidebar-" + tileMood(&r), runSidebarActiveClass(d.Tile.Latest, r.ID) }>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/hosts/%d?run=%d", d.Tile.Host.ID, r.ID)) }>
|
||||
<span class={ "runs-sidebar-dot", "runs-sidebar-dot-" + tileMood(&r) }>{ runSidebarGlyph(&r) }</span>
|
||||
<span class="runs-sidebar-id">{ fmt.Sprintf("#%d", r.ID) }</span>
|
||||
<span class="runs-sidebar-started">{ relativeTime(r.StartedAt) }</span>
|
||||
<span class="runs-sidebar-duration">{ runDuration(&r) }</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
|
||||
// RenderDetailSummaryString, RenderDetailActionsString,
|
||||
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
|
||||
// component to a string so the orchestrator can publish SSE fragments
|
||||
@@ -259,37 +326,98 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
|
||||
// Switching is pure CSS: hidden radio inputs drive sibling-selector
|
||||
// visibility on the panes. Each pane carries its own sse-swap target so
|
||||
// live events append only to the relevant pane. The All pane is seeded
|
||||
// with replay HTML so reload on an in-flight run still shows history.
|
||||
templ LogTabs(runID int64, replay string) {
|
||||
<section class="detail-section log-section">
|
||||
<h2>Log</h2>
|
||||
<div class="log-tabs">
|
||||
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-input log-tab-all" checked/>
|
||||
<label for={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-label">All</label>
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class={ "log-tab-input", "log-tab-" + s }/>
|
||||
<label for={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class="log-tab-label">{ s }</label>
|
||||
}
|
||||
<div
|
||||
class="log-pane log-pane-all"
|
||||
id={ fmt.Sprintf("log-%d", runID) }
|
||||
sse-swap={ fmt.Sprintf("log-%d", runID) }
|
||||
hx-swap="beforeend show:bottom"
|
||||
>
|
||||
@templ.Raw(replay)
|
||||
</div>
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
<div
|
||||
class={ "log-pane", "log-pane-" + s }
|
||||
id={ fmt.Sprintf("log-%d-%s", runID, s) }
|
||||
sse-swap={ fmt.Sprintf("log-%d-%s", runID, s) }
|
||||
hx-swap="beforeend show:bottom"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
// stageForName returns the persisted Stage row for a given name, or a
|
||||
// synthetic pending-state stub when no row has been seeded yet (e.g.
|
||||
// the run is still in a pre-stage). Keeps the template free of nil
|
||||
// checks and ghost logic — ActiveStep always gets a concrete Stage.
|
||||
func stageForName(stages []model.Stage, name string) model.Stage {
|
||||
for _, s := range stages {
|
||||
if s.Name == name {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return model.Stage{Name: name, State: model.StagePending}
|
||||
}
|
||||
|
||||
// runSidebarActiveClass marks the row for the currently-viewed run so
|
||||
// CSS can highlight it. Empty string (no class added) when the row isn't
|
||||
// the active one.
|
||||
func runSidebarActiveClass(viewed *model.Run, rowID int64) string {
|
||||
if viewed != nil && viewed.ID == rowID {
|
||||
return "runs-sidebar-active"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runDuration formats the elapsed time for a run using the same buckets
|
||||
// as stageDuration. In-flight runs clock from StartedAt to now so the
|
||||
// header duration keeps updating on each SSE tick.
|
||||
func runDuration(r *model.Run) string {
|
||||
if r == nil || r.StartedAt.IsZero() {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if r.CompletedAt != nil {
|
||||
end = *r.CompletedAt
|
||||
}
|
||||
d := end.Sub(r.StartedAt)
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||
case d < 10*time.Second:
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
|
||||
default:
|
||||
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
|
||||
}
|
||||
}
|
||||
|
||||
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago"
|
||||
// for the runs-sidebar. Future times (clock skew on the host) render as
|
||||
// "now" so the sidebar never shows nonsense.
|
||||
func relativeTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
d := time.Since(t)
|
||||
if d < 0 {
|
||||
return "now"
|
||||
}
|
||||
if d < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm ago", int(d/time.Minute))
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh ago", int(d/time.Hour))
|
||||
}
|
||||
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
|
||||
}
|
||||
|
||||
// runSidebarGlyph mirrors stageMarker for run-state: ✓ / ! / ● / –.
|
||||
// Used inside the sidebar dot so the color + glyph carry redundant
|
||||
// meaning.
|
||||
func runSidebarGlyph(r *model.Run) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
switch r.State {
|
||||
case model.StateCompleted:
|
||||
return "✓"
|
||||
case model.StateFailed, model.StateFailedHolding:
|
||||
return "!"
|
||||
case model.StateReleased, model.StateCancelled:
|
||||
return "–"
|
||||
}
|
||||
if r.State.IsTerminal() {
|
||||
return ""
|
||||
}
|
||||
return "●"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,18 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// HostTile renders a single dashboard card. The whole tile is a link
|
||||
// to /hosts/{id} (via a CSS-overlay <a>) — every control beyond the one
|
||||
// primary action lives on the detail page. It's the SSE-swap target
|
||||
// for per-host tile refreshes (`tile-N`).
|
||||
// HostTile renders a single dashboard card as a mini run-view. The whole
|
||||
// tile is a link to /hosts/{id} (via a CSS-overlay <a>) — every control
|
||||
// beyond the one primary action lives on the detail page. It's the SSE-
|
||||
// swap target for per-host tile refreshes (`tile-N`). The step list is
|
||||
// a compact vertical strip of the 9 canonical stages with just a
|
||||
// coloured dot per stage; operators can read run health at a glance
|
||||
// across the whole dashboard without drilling in.
|
||||
templ HostTile(t TileData) {
|
||||
<article
|
||||
id={ fmt.Sprintf("host-%d", t.Host.ID) }
|
||||
@@ -27,6 +32,17 @@ templ HostTile(t TileData) {
|
||||
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
||||
</div>
|
||||
</header>
|
||||
if t.Latest != nil {
|
||||
<div class="tile-meta-row">
|
||||
<span class="tile-run-id">{ fmt.Sprintf("#%d", t.Latest.ID) }</span>
|
||||
<span class="tile-run-duration">{ runDuration(t.Latest) }</span>
|
||||
</div>
|
||||
}
|
||||
<ol class="tile-steplist">
|
||||
for _, name := range store.DefaultStageOrder {
|
||||
@tileStep(stageForName(t.Stages, name))
|
||||
}
|
||||
</ol>
|
||||
<div class="tile-primary-action">
|
||||
if canStart(t) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
|
||||
@@ -49,6 +65,17 @@ templ HostTile(t TileData) {
|
||||
</article>
|
||||
}
|
||||
|
||||
// tileStep renders one entry of the tile's mini step-list: a small
|
||||
// coloured dot plus the short stage name. Kept as its own templ so the
|
||||
// markup stays consistent with the detail page's larger stage-dot
|
||||
// elements (same class prefix, different size via the `-sm` modifier).
|
||||
templ tileStep(s model.Stage) {
|
||||
<li class={ "tile-step", "tile-step-" + string(s.State) }>
|
||||
<span class={ "stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State) }>{ stageMarker(string(s.State)) }</span>
|
||||
<span class="tile-step-name">{ s.Name }</span>
|
||||
</li>
|
||||
}
|
||||
|
||||
func canOverrideWipe(r *model.Run) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
|
||||
@@ -13,13 +13,18 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// HostTile renders a single dashboard card. The whole tile is a link
|
||||
// to /hosts/{id} (via a CSS-overlay <a>) — every control beyond the one
|
||||
// primary action lives on the detail page. It's the SSE-swap target
|
||||
// for per-host tile refreshes (`tile-N`).
|
||||
// HostTile renders a single dashboard card as a mini run-view. The whole
|
||||
// tile is a link to /hosts/{id} (via a CSS-overlay <a>) — every control
|
||||
// beyond the one primary action lives on the detail page. It's the SSE-
|
||||
// swap target for per-host tile refreshes (`tile-N`). The step list is
|
||||
// a compact vertical strip of the 9 canonical stages with just a
|
||||
// coloured dot per stage; operators can read run health at a glance
|
||||
// across the whole dashboard without drilling in.
|
||||
func HostTile(t TileData) templ.Component {
|
||||
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
|
||||
@@ -53,7 +58,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 17, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -79,7 +84,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -92,7 +97,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -105,7 +110,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -118,7 +123,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +158,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 31, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -166,77 +171,222 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, Col: 51}
|
||||
}
|
||||
_, 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, "</div></div></header><div class=\"tile-primary-action\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></header>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if canStart(t) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form method=\"post\" action=\"")
|
||||
if t.Latest != nil {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"tile-meta-row\"><span class=\"tile-run-id\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", t.Latest.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 37, Col: 63}
|
||||
}
|
||||
_, 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, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> <span class=\"tile-run-duration\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canStartIfOnline(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(t.Latest))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canCancel(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 templ.SafeURL
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 42, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 38, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if hasReport(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a class=\"button-like\" href=\"")
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<ol class=\"tile-steplist\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, name := range store.DefaultStageOrder {
|
||||
templ_7745c5c3_Err = tileStep(stageForName(t.Stages, name)).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</ol><div class=\"tile-primary-action\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if canStart(t) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
|
||||
templ_7745c5c3_Var15, 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: `internal/web/templates/host_tile.templ`, Line: 46, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 89}
|
||||
}
|
||||
_, 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, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canStartIfOnline(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if canCancel(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 templ.SafeURL
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 58, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else if hasReport(t.Latest) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<a class=\"button-like\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 62, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></article>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></article>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// tileStep renders one entry of the tile's mini step-list: a small
|
||||
// coloured dot plus the short stage name. Kept as its own templ so the
|
||||
// markup stays consistent with the detail page's larger stage-dot
|
||||
// elements (same class prefix, different size via the `-sm` modifier).
|
||||
func tileStep(s model.Stage) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var18 == nil {
|
||||
templ_7745c5c3_Var18 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var19 = []any{"tile-step", "tile-step-" + string(s.State)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<li class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 = []any{"stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var21).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(s.State)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 74, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span> <span class=\"tile-step-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 75, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
func TestHumanAgoFrom(t *testing.T) {
|
||||
@@ -96,6 +98,99 @@ func TestHostTile_DisabledStartWhenOffline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostTile_MiniRunView asserts the tile renders a step-list entry
|
||||
// for every canonical stage, colours the dots according to the mixed
|
||||
// stage states in the fixture, and surfaces the run id + duration in
|
||||
// the meta row. This is the contract the dashboard leans on: the
|
||||
// operator should be able to read run health across all tiles without
|
||||
// drilling into any of them.
|
||||
func TestHostTile_MiniRunView(t *testing.T) {
|
||||
now := time.Now()
|
||||
started := now.Add(-3 * time.Minute)
|
||||
latest := &model.Run{
|
||||
ID: 17,
|
||||
State: model.StateSMART,
|
||||
StartedAt: started,
|
||||
}
|
||||
// Mixed states: first two stages passed, SMART running, rest pending.
|
||||
stages := []model.Stage{
|
||||
{Name: "Inventory", State: model.StagePassed},
|
||||
{Name: "SpecValidate", State: model.StagePassed},
|
||||
{Name: "SMART", State: model.StageRunning},
|
||||
}
|
||||
data := TileData{
|
||||
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
|
||||
Latest: latest,
|
||||
Stages: stages,
|
||||
LastSeenAt: &now,
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
html := buf.String()
|
||||
|
||||
// Step list exists and contains every canonical stage name so the
|
||||
// operator reads a full 9-dot strip regardless of how far the run got.
|
||||
if !strings.Contains(html, `<ol class="tile-steplist">`) {
|
||||
t.Fatalf("tile missing step list: %s", html)
|
||||
}
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, s)
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("tile missing step name %q: %s", s, html)
|
||||
}
|
||||
}
|
||||
// Colours: the two passed stages got passed dots; SMART got a running
|
||||
// dot; CPUStress (no fixture row) falls back to pending.
|
||||
mustContain := []string{
|
||||
`stage-dot stage-dot-sm stage-dot-passed`,
|
||||
`stage-dot stage-dot-sm stage-dot-running`,
|
||||
`stage-dot stage-dot-sm stage-dot-pending`,
|
||||
}
|
||||
for _, c := range mustContain {
|
||||
if !strings.Contains(html, c) {
|
||||
t.Fatalf("tile missing expected dot classes %q: %s", c, html)
|
||||
}
|
||||
}
|
||||
// Meta row: run id + a duration string (minutes for a 3m-old run).
|
||||
if !strings.Contains(html, `#17`) {
|
||||
t.Fatalf("tile missing run id #17: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `class="tile-run-duration"`) {
|
||||
t.Fatalf("tile missing duration element: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostTile_GhostSteplist: a never-run host still gets a 9-dot
|
||||
// ghost strip (all pending). Keeps the tile height stable so the
|
||||
// dashboard grid doesn't reflow as hosts gain their first run.
|
||||
func TestHostTile_GhostSteplist(t *testing.T) {
|
||||
now := time.Now()
|
||||
data := TileData{
|
||||
Host: model.Host{ID: 1, Name: "fresh", MAC: "aa:bb:cc:dd:ee:01"},
|
||||
LastSeenAt: &now,
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
html := buf.String()
|
||||
for _, s := range store.DefaultStageOrder {
|
||||
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, s)
|
||||
if !strings.Contains(html, want) {
|
||||
t.Fatalf("ghost tile missing stage %q: %s", s, html)
|
||||
}
|
||||
}
|
||||
if strings.Contains(html, `stage-dot-passed`) || strings.Contains(html, `stage-dot-running`) || strings.Contains(html, `stage-dot-failed`) {
|
||||
t.Fatalf("ghost tile should have only pending dots: %s", html)
|
||||
}
|
||||
// No run → no meta row (suppresses "#0 · 0s" when no run exists).
|
||||
if strings.Contains(html, `class="tile-run-id"`) {
|
||||
t.Fatalf("ghost tile should omit run id: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastSeenLabelAndClass(t *testing.T) {
|
||||
if got := lastSeenLabel(nil); got != "never" {
|
||||
t.Fatalf("label nil = %q, want never", got)
|
||||
|
||||
@@ -10,6 +10,7 @@ templ Layout(title string) {
|
||||
<link rel="stylesheet" href="/static/app.css"/>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.2" integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/htmx-ext-sse@2.2.2" integrity="sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI" crossorigin="anonymous"></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body hx-boost="true">
|
||||
<header class="topbar">
|
||||
|
||||
@@ -42,7 +42,7 @@ func Layout(title string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script><script src=\"/static/app.js\" defer></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 38, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 39, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -14,53 +14,58 @@ type RegistrationForm struct {
|
||||
templ Registration(form RegistrationForm) {
|
||||
@Layout("Register host") {
|
||||
<section class="form-wrap">
|
||||
<h1>Register host</h1>
|
||||
<header class="form-wrap-head">
|
||||
<h1>Register host</h1>
|
||||
<a class="button-secondary" href="/">Back to dashboard</a>
|
||||
</header>
|
||||
if form.Error != "" {
|
||||
<div class="error">{ form.Error }</div>
|
||||
}
|
||||
if form.QuickRegisterURL != "" {
|
||||
<div class="quick-register">
|
||||
<section class="detail-section quick-register">
|
||||
<h2>Quick register <span class="muted">(recommended)</span></h2>
|
||||
<p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p>
|
||||
<pre class="one-liner"><code>{ "curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash" }</code></pre>
|
||||
<p class="muted">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<details class="manual-register">
|
||||
<summary>Register manually</summary>
|
||||
<form method="post" action="/hosts" class="host-form">
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" value={ form.Name } required pattern="[A-Za-z0-9_\-\.]+" placeholder="pve-node-03"/>
|
||||
</label>
|
||||
<label>
|
||||
MAC address
|
||||
<input type="text" name="mac" value={ form.MAC } required placeholder="aa:bb:cc:dd:ee:ff"/>
|
||||
</label>
|
||||
<div class="grid-2">
|
||||
<section class="detail-section manual-register-card">
|
||||
<details class="manual-register">
|
||||
<summary><h2>Register manually</h2></summary>
|
||||
<form method="post" action="/hosts" class="host-form">
|
||||
<label>
|
||||
WoL broadcast IP
|
||||
<input type="text" name="wol_broadcast_ip" value={ form.WoLBroadcastIP } required placeholder="10.0.0.255"/>
|
||||
Name
|
||||
<input type="text" name="name" value={ form.Name } required pattern="[A-Za-z0-9_\-\.]+" placeholder="pve-node-03"/>
|
||||
</label>
|
||||
<label>
|
||||
WoL port
|
||||
<input type="number" name="wol_port" value={ defaultPort(form.WoLPort) } min="1" max="65535"/>
|
||||
MAC address
|
||||
<input type="text" name="mac" value={ form.MAC } required placeholder="aa:bb:cc:dd:ee:ff"/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Expected hardware spec (YAML)
|
||||
<textarea name="expected_spec_yaml" rows="12" required placeholder="cpu: model_match: ...">{ form.ExpectedSpecYAML }</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Notes
|
||||
<textarea name="notes" rows="3">{ form.Notes }</textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Register</button>
|
||||
<a class="button-secondary" href="/">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<div class="grid-2">
|
||||
<label>
|
||||
WoL broadcast IP
|
||||
<input type="text" name="wol_broadcast_ip" value={ form.WoLBroadcastIP } required placeholder="10.0.0.255"/>
|
||||
</label>
|
||||
<label>
|
||||
WoL port
|
||||
<input type="number" name="wol_port" value={ defaultPort(form.WoLPort) } min="1" max="65535"/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Expected hardware spec (YAML)
|
||||
<textarea name="expected_spec_yaml" rows="12" required placeholder="cpu: model_match: ...">{ form.ExpectedSpecYAML }</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Notes
|
||||
<textarea name="notes" rows="3">{ form.Notes }</textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary">Register</button>
|
||||
<a class="button-secondary" href="/">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"form-wrap\"><h1>Register host</h1>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"form-wrap\"><header class=\"form-wrap-head\"><h1>Register host</h1><a class=\"button-secondary\" href=\"/\">Back to dashboard</a></header>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 19, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 22, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -76,32 +76,32 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
}
|
||||
}
|
||||
if form.QuickRegisterURL != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><pre class=\"one-liner\"><code>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<section class=\"detail-section quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><pre class=\"one-liner\"><code>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 25, Col: 108}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 28, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></pre><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></pre><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<details class=\"manual-register\"><summary>Register manually</summary><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<section class=\"detail-section manual-register-card\"><details class=\"manual-register\"><summary><h2>Register manually</h2></summary><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 34, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -114,7 +114,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 42, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -127,7 +127,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 43, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -140,7 +140,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 51, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +153,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 52, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 127}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -166,13 +166,13 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 60, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\" class=\"btn-primary\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// subStepDuration formats a sub-step's elapsed time the same way
|
||||
// stageDuration does for pipeline nodes. Empty string when not started.
|
||||
func subStepDuration(ss model.SubStep) string {
|
||||
if ss.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if ss.CompletedAt != nil {
|
||||
end = *ss.CompletedAt
|
||||
}
|
||||
d := end.Sub(*ss.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))
|
||||
}
|
||||
}
|
||||
|
||||
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
|
||||
// state badge. StageState values reused verbatim for sub-steps.
|
||||
func subStepMarker(s model.StageState) string {
|
||||
switch s {
|
||||
case model.StagePassed:
|
||||
return "✓"
|
||||
case model.StageFailed:
|
||||
return "!"
|
||||
case model.StageRunning:
|
||||
return "●"
|
||||
case model.StageSkipped:
|
||||
return "–"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SubStepRow renders one sub-step entry for the expanded-step pane. The
|
||||
// outer <div> carries the sse-swap target keyed by (runID, stage,
|
||||
// ordinal) so the orchestrator's PublishSubStepUpdate can swap just this
|
||||
// row without touching the rest of the stage panel. hx-swap="outerHTML"
|
||||
// keeps the attributes intact across repeat swaps.
|
||||
templ SubStepRow(ss model.SubStep) {
|
||||
<div
|
||||
id={ fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal) }
|
||||
class={ "substep", "substep-" + string(ss.State) }
|
||||
sse-swap={ fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<span class={ "substep-badge", "substep-badge-" + string(ss.State) }>{ subStepMarker(ss.State) }</span>
|
||||
<span class="substep-name">{ ss.Name }</span>
|
||||
<span class="substep-duration">{ subStepDuration(ss) }</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
// RenderSubStepRowString is the one-shot renderer the orchestrator
|
||||
// registers as SubStepRenderer so it can emit substep-* SSE payloads
|
||||
// without importing the templates package directly.
|
||||
func RenderSubStepRowString(ss model.SubStep) string {
|
||||
var buf bytes.Buffer
|
||||
_ = SubStepRow(ss).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// subStepDuration formats a sub-step's elapsed time the same way
|
||||
// stageDuration does for pipeline nodes. Empty string when not started.
|
||||
func subStepDuration(ss model.SubStep) string {
|
||||
if ss.StartedAt == nil {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if ss.CompletedAt != nil {
|
||||
end = *ss.CompletedAt
|
||||
}
|
||||
d := end.Sub(*ss.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))
|
||||
}
|
||||
}
|
||||
|
||||
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
|
||||
// state badge. StageState values reused verbatim for sub-steps.
|
||||
func subStepMarker(s model.StageState) string {
|
||||
switch s {
|
||||
case model.StagePassed:
|
||||
return "✓"
|
||||
case model.StageFailed:
|
||||
return "!"
|
||||
case model.StageRunning:
|
||||
return "●"
|
||||
case model.StageSkipped:
|
||||
return "–"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SubStepRow renders one sub-step entry for the expanded-step pane. The
|
||||
// outer <div> carries the sse-swap target keyed by (runID, stage,
|
||||
// ordinal) so the orchestrator's PublishSubStepUpdate can swap just this
|
||||
// row without touching the rest of the stage panel. hx-swap="outerHTML"
|
||||
// keeps the attributes intact across repeat swaps.
|
||||
func SubStepRow(ss model.SubStep) 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)
|
||||
var templ_7745c5c3_Var2 = []any{"substep", "substep-" + string(ss.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, 1, "<div id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 63, Col: 74}
|
||||
}
|
||||
_, 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, "\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, 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/substep_row.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 65, Col: 80}
|
||||
}
|
||||
_, 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, "\" hx-swap=\"outerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 = []any{"substep-badge", "substep-badge-" + string(ss.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, 5, "<span 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/substep_row.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, 6, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subStepMarker(ss.State))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 68, Col: 96}
|
||||
}
|
||||
_, 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, "</span> <span class=\"substep-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ss.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 69, Col: 38}
|
||||
}
|
||||
_, 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=\"substep-duration\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(subStepDuration(ss))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 70, Col: 54}
|
||||
}
|
||||
_, 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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RenderSubStepRowString is the one-shot renderer the orchestrator
|
||||
// registers as SubStepRenderer so it can emit substep-* SSE payloads
|
||||
// without importing the templates package directly.
|
||||
func RenderSubStepRowString(ss model.SubStep) string {
|
||||
var buf bytes.Buffer
|
||||
_ = SubStepRow(ss).Render(context.Background(), &buf)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
Reference in New Issue
Block a user