feat(ui): slim dashboard tile to hostname + online/offline only
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 <noreply@anthropic.com>
This commit is contained in:
@@ -117,14 +117,8 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.tile > *:not(.tile-link) { position: relative; z-index: 1; }
|
.tile > *:not(.tile-link) { position: relative; z-index: 1; }
|
||||||
.tile-primary-action { display: flex; gap: 8px; }
|
|
||||||
.tile-primary-action .inline { margin: 0; }
|
|
||||||
.tile-primary-action:empty { display: none; }
|
|
||||||
.tile-head { display: flex; justify-content: space-between; align-items: center; }
|
.tile-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.tile-name { font-weight: 600; }
|
.tile-name { font-weight: 600; }
|
||||||
.tile-header-right { display: flex; align-items: center; gap: 10px; }
|
|
||||||
.tile-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 {
|
.tile-last-seen {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
|||||||
@@ -9,45 +9,22 @@ import (
|
|||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostTile renders a single dashboard card: hostname, heartbeat badge,
|
// HostTile renders a single dashboard card: hostname + heartbeat badge
|
||||||
// latest run status, and the primary action (Start / Cancel / View
|
// only. Everything else (run status, controls, reports) lives on the
|
||||||
// report). The whole tile is a link to /hosts/{id} via a CSS-overlay
|
// host page — the whole tile is a link there via a CSS-overlay <a>.
|
||||||
// <a>; every deeper control lives on the host page or the run page.
|
|
||||||
// It's the SSE-swap target for per-host tile refreshes (`tile-N`).
|
// It's the SSE-swap target for per-host tile refreshes (`tile-N`).
|
||||||
templ HostTile(t TileData) {
|
templ HostTile(t TileData) {
|
||||||
<article
|
<article
|
||||||
id={ fmt.Sprintf("host-%d", t.Host.ID) }
|
id={ fmt.Sprintf("host-%d", t.Host.ID) }
|
||||||
class={ "tile", "tile-" + tileMood(t.Latest) }
|
class="tile"
|
||||||
sse-swap={ fmt.Sprintf("tile-%d", t.Host.ID) }
|
sse-swap={ fmt.Sprintf("tile-%d", t.Host.ID) }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<a class="tile-link" href={ templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)) } aria-label={ "Open " + t.Host.Name }></a>
|
<a class="tile-link" href={ templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)) } aria-label={ "Open " + t.Host.Name }></a>
|
||||||
<header class="tile-head">
|
<header class="tile-head">
|
||||||
<div class="tile-name">{ t.Host.Name }</div>
|
<div class="tile-name">{ t.Host.Name }</div>
|
||||||
<div class="tile-header-right">
|
<span class={ "tile-last-seen", lastSeenClass(t.LastSeenAt) }>{ lastSeenLabel(t.LastSeenAt) }</span>
|
||||||
<span class={ "tile-last-seen", lastSeenClass(t.LastSeenAt) }>{ lastSeenLabel(t.LastSeenAt) }</span>
|
|
||||||
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="tile-primary-action">
|
|
||||||
if canStart(t) {
|
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
|
|
||||||
<label class="tile-nd-toggle">
|
|
||||||
<input type="checkbox" name="non_destructive" value="1"/>
|
|
||||||
Non-destructive
|
|
||||||
</label>
|
|
||||||
<button type="submit">Start vetting</button>
|
|
||||||
</form>
|
|
||||||
} else if canStartIfOnline(t.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 if canCancel(t.Latest) {
|
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)) } 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>
|
|
||||||
} else if hasReport(t.Latest) {
|
|
||||||
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,30 +42,6 @@ func hasReport(r *model.Run) bool {
|
|||||||
return r != nil && r.State == model.StateCompleted
|
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 —
|
// canCancel is true for any non-terminal run, plus FailedHolding —
|
||||||
// a held run technically classifies as terminal for the pipeline but
|
// a held run technically classifies as terminal for the pipeline but
|
||||||
// the host is still live on the SSH hold prompt, and the operator
|
// the host is still live on the SSH hold prompt, and the operator
|
||||||
|
|||||||
@@ -17,10 +17,9 @@ import (
|
|||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostTile renders a single dashboard card: hostname, heartbeat badge,
|
// HostTile renders a single dashboard card: hostname + heartbeat badge
|
||||||
// latest run status, and the primary action (Start / Cancel / View
|
// only. Everything else (run status, controls, reports) lives on the
|
||||||
// report). The whole tile is a link to /hosts/{id} via a CSS-overlay
|
// host page — the whole tile is a link there via a CSS-overlay <a>.
|
||||||
// <a>; every deeper control lives on the host page or the run page.
|
|
||||||
// It's the SSE-swap target for per-host tile refreshes (`tile-N`).
|
// It's the SSE-swap target for per-host tile refreshes (`tile-N`).
|
||||||
func HostTile(t TileData) templ.Component {
|
func HostTile(t TileData) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
@@ -43,202 +42,107 @@ func HostTile(t TileData) templ.Component {
|
|||||||
templ_7745c5c3_Var1 = templ.NopComponent
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
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, "<article id=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<article id=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID))
|
templ_7745c5c3_Var2, 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: 19, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 18, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"tile\" sse-swap=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 20, Col: 46}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" hx-swap=\"outerHTML\"><a class=\"tile-link\" href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 templ.SafeURL
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
templ_7745c5c3_Var4, 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: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 23, Col: 80}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" sse-swap=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" aria-label=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
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("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: 21, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 23, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap=\"outerHTML\"><a class=\"tile-link\" href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"></a><header class=\"tile-head\"><div class=\"tile-name\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var6 templ.SafeURL
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)))
|
templ_7745c5c3_Var6, 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: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" aria-label=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 = []any{"tile-last-seen", lastSeenClass(t.LastSeenAt)}
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name)
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...)
|
||||||
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))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></a><header class=\"tile-head\"><div class=\"tile-name\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<span class=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String())
|
||||||
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: 26, Col: 39}
|
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_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"tile-header-right\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var9 = []any{"tile-last-seen", lastSeenClass(t.LastSeenAt)}
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<span class=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</span></header></article>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var10 string
|
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).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_Var10))
|
|
||||||
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_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, "</span><div class=\"tile-status\">")
|
|
||||||
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, "</div></div></header><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, 12, "<form method=\"post\" action=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var13 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
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))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"inline tile-start-form\"><label class=\"tile-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive</label> <button type=\"submit\">Start vetting</button></form>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else if canStartIfOnline(t.Latest) {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<button type=\"button\" disabled title=\"host is not heartbeating — install the reporter via /register/quick.sh on the target host\">Start vetting</button>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else if canCancel(t.Latest) {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form method=\"post\" action=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var14 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 44, Col: 90}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" class=\"inline tile-cancel-form\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"danger\">Cancel run</button></form>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
} else if hasReport(t.Latest) {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a class=\"button-like\" href=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 templ.SafeURL
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
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))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div></article>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -260,30 +164,6 @@ func hasReport(r *model.Run) bool {
|
|||||||
return r != nil && r.State == model.StateCompleted
|
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 —
|
// canCancel is true for any non-terminal run, plus FailedHolding —
|
||||||
// a held run technically classifies as terminal for the pipeline but
|
// a held run technically classifies as terminal for the pipeline but
|
||||||
// the host is still live on the SSH hold prompt, and the operator
|
// the host is still live on the SSH hold prompt, and the operator
|
||||||
|
|||||||
@@ -40,17 +40,15 @@ func TestHumanAgoFrom(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestHostTile_OverlayLink asserts the tile includes the tile-link <a>
|
// TestHostTile_OverlayLink asserts the tile includes the tile-link <a>
|
||||||
// that makes the whole card clickable. The action button stays a
|
// that makes the whole card clickable, and that the dashboard tile is
|
||||||
// sibling element, so CSS (z-index) keeps it on top of the overlay.
|
// stripped down to just hostname + last-seen badge — no action controls
|
||||||
//
|
// or run-state UI clogging the dashboard at scale.
|
||||||
// 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.
|
|
||||||
func TestHostTile_OverlayLink(t *testing.T) {
|
func TestHostTile_OverlayLink(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
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"},
|
||||||
LastSeenAt: &now,
|
LastSeenAt: &now,
|
||||||
|
Latest: &model.Run{State: model.StateCompleted},
|
||||||
}
|
}
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
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"`) {
|
if !strings.Contains(html, `class="tile-link"`) {
|
||||||
t.Fatalf("tile missing tile-link class: %s", html)
|
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
|
// Dropped content that used to live on the tile — confirm it has
|
||||||
// actually moved off so the slim-down is real.
|
// 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) {
|
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
|
// TestHostTile_NoStageStrip: the tile no longer carries the Phase 3
|
||||||
// per-stage mini run-view — the runs-table on /hosts/{id} owns the
|
// per-stage mini run-view — the runs-table on /hosts/{id} owns the
|
||||||
// stage-strip now. Guards against the regression that would bring
|
// stage-strip now. Guards against the regression that would bring
|
||||||
|
|||||||
Reference in New Issue
Block a user