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