From c11573eeeb7173d83194334ab37c879f6bcde5c2 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 20 Apr 2026 22:56:05 -0400 Subject: [PATCH] feat(ui): slim dashboard tile to hostname + online/offline only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run status, Start/Cancel/View controls, and non-destructive toggle all live on /hosts/{id} — duplicating them on the dashboard tile clogged the grid and wouldn't scale past a handful of hosts. Co-Authored-By: Claude Opus 4.7 --- internal/web/static/app.css | 6 - internal/web/templates/host_tile.templ | 57 +----- internal/web/templates/host_tile_templ.go | 208 +++++----------------- internal/web/templates/host_tile_test.go | 44 ++--- 4 files changed, 61 insertions(+), 254 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index fbdf3d1..91ea11e 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -117,14 +117,8 @@ button.danger:hover { background: rgba(229,100,102,.1); } text-decoration: none; } .tile > *:not(.tile-link) { position: relative; z-index: 1; } -.tile-primary-action { display: flex; gap: 8px; } -.tile-primary-action .inline { margin: 0; } -.tile-primary-action:empty { display: none; } .tile-head { display: flex; justify-content: space-between; align-items: center; } .tile-name { font-weight: 600; } -.tile-header-right { display: flex; align-items: center; gap: 10px; } -.tile-status { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; } -.tile-idle .tile-status { color: var(--text-dim); } .tile-last-seen { font-family: var(--mono); diff --git a/internal/web/templates/host_tile.templ b/internal/web/templates/host_tile.templ index 86c31f6..10b627e 100644 --- a/internal/web/templates/host_tile.templ +++ b/internal/web/templates/host_tile.templ @@ -9,45 +9,22 @@ import ( "vetting/internal/model" ) -// HostTile renders a single dashboard card: hostname, heartbeat badge, -// latest run status, and the primary action (Start / Cancel / View -// report). The whole tile is a link to /hosts/{id} via a CSS-overlay -// ; every deeper control lives on the host page or the run page. +// HostTile renders a single dashboard card: hostname + heartbeat badge +// only. Everything else (run status, controls, reports) lives on the +// host page — the whole tile is a link there via a CSS-overlay . // It's the SSE-swap target for per-host tile refreshes (`tile-N`). templ HostTile(t TileData) {
{ t.Host.Name }
-
- { lastSeenLabel(t.LastSeenAt) } -
{ tileStatus(t.Latest) }
-
+ { lastSeenLabel(t.LastSeenAt) }
-
- if canStart(t) { -
- - -
- } else if canStartIfOnline(t.Latest) { - - } else if canCancel(t.Latest) { -
- -
- } else if hasReport(t.Latest) { - View report - } -
} @@ -65,30 +42,6 @@ func hasReport(r *model.Run) bool { return r != nil && r.State == model.StateCompleted } -// canStart gates the Start button on two things: the run is in a state -// that accepts a fresh start, AND the host is currently heartbeating. -// The heartbeat check mirrors the StartRun handler's preflight so the -// button never offers a click that the server would reject with 409. -func canStart(t TileData) bool { - if !canStartIfOnline(t.Latest) { - return false - } - if t.LastSeenAt == nil { - return false - } - return time.Since(*t.LastSeenAt) <= 60*time.Second -} - -// canStartIfOnline is the run-state half of canStart, split out so the -// template can distinguish "waiting on run to end" (no button) from -// "run is done but host is offline" (disabled button with tooltip). -func canStartIfOnline(r *model.Run) bool { - if r == nil { - return true - } - return r.State.IsTerminal() -} - // canCancel is true for any non-terminal run, plus FailedHolding — // a held run technically classifies as terminal for the pipeline but // the host is still live on the SSH hold prompt, and the operator diff --git a/internal/web/templates/host_tile_templ.go b/internal/web/templates/host_tile_templ.go index 0f4bc24..5c624e0 100644 --- a/internal/web/templates/host_tile_templ.go +++ b/internal/web/templates/host_tile_templ.go @@ -17,10 +17,9 @@ import ( "vetting/internal/model" ) -// HostTile renders a single dashboard card: hostname, heartbeat badge, -// latest run status, and the primary action (Start / Cancel / View -// report). The whole tile is a link to /hosts/{id} via a CSS-overlay -// ; every deeper control lives on the host page or the run page. +// HostTile renders a single dashboard card: hostname + heartbeat badge +// only. Everything else (run status, controls, reports) lives on the +// host page — the whole tile is a link there via a CSS-overlay . // It's the SSE-swap target for per-host tile refreshes (`tile-N`). func HostTile(t TileData) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { @@ -43,202 +42,107 @@ func HostTile(t TileData) templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var2 = []any{"tile", "tile-" + tileMood(t.Latest)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 templ.SafeURL - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID))) + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 25, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" aria-label=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 117} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + var templ_7745c5c3_Var7 = []any{"tile-last-seen", lastSeenClass(t.LastSeenAt)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 = []any{"tile-last-seen", lastSeenClass(t.LastSeenAt)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 94} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 28, Col: 95} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 51} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if canStart(t) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if canStartIfOnline(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if canCancel(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if hasReport(t.Latest) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "View report") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -260,30 +164,6 @@ func hasReport(r *model.Run) bool { return r != nil && r.State == model.StateCompleted } -// canStart gates the Start button on two things: the run is in a state -// that accepts a fresh start, AND the host is currently heartbeating. -// The heartbeat check mirrors the StartRun handler's preflight so the -// button never offers a click that the server would reject with 409. -func canStart(t TileData) bool { - if !canStartIfOnline(t.Latest) { - return false - } - if t.LastSeenAt == nil { - return false - } - return time.Since(*t.LastSeenAt) <= 60*time.Second -} - -// canStartIfOnline is the run-state half of canStart, split out so the -// template can distinguish "waiting on run to end" (no button) from -// "run is done but host is offline" (disabled button with tooltip). -func canStartIfOnline(r *model.Run) bool { - if r == nil { - return true - } - return r.State.IsTerminal() -} - // canCancel is true for any non-terminal run, plus FailedHolding — // a held run technically classifies as terminal for the pipeline but // the host is still live on the SSH hold prompt, and the operator diff --git a/internal/web/templates/host_tile_test.go b/internal/web/templates/host_tile_test.go index b7fd510..ed6002f 100644 --- a/internal/web/templates/host_tile_test.go +++ b/internal/web/templates/host_tile_test.go @@ -40,17 +40,15 @@ func TestHumanAgoFrom(t *testing.T) { } // TestHostTile_OverlayLink asserts the tile includes the tile-link -// that makes the whole card clickable. The action button stays a -// sibling element, so CSS (z-index) keeps it on top of the overlay. -// -// Heartbeat must be fresh because canStart now gates on LastSeenAt — -// an offline host renders a disabled button (no form), which is -// covered by TestHostTile_DisabledStartWhenOffline below. +// that makes the whole card clickable, and that the dashboard tile is +// stripped down to just hostname + last-seen badge — no action controls +// or run-state UI clogging the dashboard at scale. func TestHostTile_OverlayLink(t *testing.T) { now := time.Now() data := TileData{ Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, LastSeenAt: &now, + Latest: &model.Run{State: model.StateCompleted}, } var buf strings.Builder if err := HostTile(data).Render(context.Background(), &buf); err != nil { @@ -63,39 +61,21 @@ func TestHostTile_OverlayLink(t *testing.T) { if !strings.Contains(html, `class="tile-link"`) { t.Fatalf("tile missing tile-link class: %s", html) } - // Fresh heartbeat + no run → Start form must render. - if !strings.Contains(html, `/hosts/42/start`) { - t.Fatalf("expected Start vetting form in tile: %s", html) - } // Dropped content that used to live on the tile — confirm it has // actually moved off so the slim-down is real. - for _, dropped := range []string{`tile-meta`, `tile-log`, `tile-actions`, `tile-hold`} { + for _, dropped := range []string{ + `tile-meta`, `tile-log`, `tile-actions`, `tile-hold`, + `tile-primary-action`, `tile-status`, `tile-start-form`, + `tile-nd-toggle`, `tile-cancel-form`, + `/hosts/42/start`, `/hosts/42/cancel`, + `Start vetting`, `Non-destructive`, `Cancel run`, `View report`, + } { if strings.Contains(html, dropped) { - t.Errorf("slim tile still contains dropped class %q", dropped) + t.Errorf("slim tile still contains dropped content %q", dropped) } } } -// TestHostTile_DisabledStartWhenOffline: no heartbeat → disabled button -// with the quick.sh tooltip, not a submittable form. Mirrors the -// server-side StartRun 409 so the UI matches the handler. -func TestHostTile_DisabledStartWhenOffline(t *testing.T) { - data := TileData{ - Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"}, - } - var buf strings.Builder - if err := HostTile(data).Render(context.Background(), &buf); err != nil { - t.Fatalf("render: %v", err) - } - html := buf.String() - if strings.Contains(html, `/hosts/42/start`) { - t.Fatalf("offline host should not expose a Start form: %s", html) - } - if !strings.Contains(html, `disabled`) || !strings.Contains(html, `quick.sh`) { - t.Fatalf("expected disabled Start button with quick.sh tooltip: %s", html) - } -} - // TestHostTile_NoStageStrip: the tile no longer carries the Phase 3 // per-stage mini run-view — the runs-table on /hosts/{id} owns the // stage-strip now. Guards against the regression that would bring