bb658a8435
CI / Lint + build + test (push) Has been cancelled
Click a tile to open /hosts/{id} — the canonical control surface per
host. Timeline renders every pre-stage, stage, and terminal node in
order, with the current one pulsing, failed ones flagged, and
downstream ones dimmed as skipped. Detail page shows summary, hold
card (when holding), all action buttons, spec diffs, a full-height
log pane, and a collapsed expected-spec YAML.
Tile slims to name, last-seen, status, and one primary action; a
CSS-overlay <a> makes the whole card clickable while buttons stay
receptive via z-index.
Runner.publishTileUpdate now also emits pipeline-{runID} fragments,
and CompleteStage wraps Stages.CompleteByName so stage completions
advance the timeline live — without this the dots only moved on
state transitions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
331 lines
12 KiB
Go
331 lines
12 KiB
Go
// 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"
|
||
)
|
||
|
||
// HostTile renders a single dashboard card. The whole tile is a link
|
||
// to /hosts/{id} (via a CSS-overlay <a>) — every control beyond the one
|
||
// primary action lives on the detail page. It's the SSE-swap target
|
||
// for per-host tile refreshes (`tile-N`).
|
||
func HostTile(t TileData) templ.Component {
|
||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||
return templ_7745c5c3_CtxErr
|
||
}
|
||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||
if !templ_7745c5c3_IsBuffer {
|
||
defer func() {
|
||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||
if templ_7745c5c3_Err == nil {
|
||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||
}
|
||
}()
|
||
}
|
||
ctx = templ.InitializeContext(ctx)
|
||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||
if templ_7745c5c3_Var1 == nil {
|
||
templ_7745c5c3_Var1 = templ.NopComponent
|
||
}
|
||
ctx = templ.ClearChildren(ctx)
|
||
var templ_7745c5c3_Var2 = []any{"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=\"")
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
var templ_7745c5c3_Var3 string
|
||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID))
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 17, Col: 40}
|
||
}
|
||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"")
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
var templ_7745c5c3_Var4 string
|
||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
|
||
}
|
||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" sse-swap=\"")
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
var templ_7745c5c3_Var5 string
|
||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID))
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 46}
|
||
}
|
||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap=\"outerHTML\"><a class=\"tile-link\" href=\"")
|
||
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)))
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 80}
|
||
}
|
||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" aria-label=\"")
|
||
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: 22, Col: 117}
|
||
}
|
||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></a><header class=\"tile-head\"><div class=\"tile-name\">")
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
var templ_7745c5c3_Var8 string
|
||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 39}
|
||
}
|
||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div><div class=\"tile-header-right\">")
|
||
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...)
|
||
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_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: 26, 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: 27, 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.Latest) {
|
||
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: 32, 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\"><button type=\"submit\">Start vetting</button></form>")
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
} else if hasReport(t.Latest) {
|
||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a class=\"button-like\" href=\"")
|
||
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("/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: 36, Col: 88}
|
||
}
|
||
_, 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, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
}
|
||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></article>")
|
||
if templ_7745c5c3_Err != nil {
|
||
return templ_7745c5c3_Err
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func canOverrideWipe(r *model.Run) bool {
|
||
if r == nil {
|
||
return false
|
||
}
|
||
return r.State == model.StateFailedHolding && r.FailedStage == "Storage"
|
||
}
|
||
|
||
// hasReport is true once the reporting stage has produced an HTML
|
||
// artifact. We cheat slightly: Completed runs always have one, and
|
||
// that's the only state in which the tile wants to surface a link.
|
||
func hasReport(r *model.Run) bool {
|
||
return r != nil && r.State == model.StateCompleted
|
||
}
|
||
|
||
func canStart(r *model.Run) bool {
|
||
if r == nil {
|
||
return true
|
||
}
|
||
switch r.State {
|
||
case model.StateCompleted, model.StateReleased, model.StateFailedHolding:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func tileStatus(r *model.Run) string {
|
||
if r == nil {
|
||
return "Idle"
|
||
}
|
||
return string(r.State)
|
||
}
|
||
|
||
func tileMood(r *model.Run) string {
|
||
if r == nil {
|
||
return "idle"
|
||
}
|
||
switch r.State {
|
||
case model.StateCompleted:
|
||
return "pass"
|
||
case model.StateFailed, model.StateFailedHolding:
|
||
return "fail"
|
||
case model.StateReleased:
|
||
return "idle"
|
||
}
|
||
return "active"
|
||
}
|
||
|
||
func sshInvocation(keyPath, ip string) string {
|
||
if keyPath == "" {
|
||
return "ssh root@" + ip + " (hold key not yet recorded)"
|
||
}
|
||
return fmt.Sprintf("ssh -i %s root@%s", keyPath, ip)
|
||
}
|
||
|
||
// RenderTileString renders a single tile fragment so the orchestrator
|
||
// can publish it over SSE without threading a context through every
|
||
// event publisher.
|
||
func RenderTileString(t TileData) string {
|
||
var buf bytes.Buffer
|
||
_ = HostTile(t).Render(context.Background(), &buf)
|
||
return buf.String()
|
||
}
|
||
|
||
// lastSeenLabel renders the host-mode agent's liveness into a short
|
||
// badge: "never" if the host has never heartbeated, "online" within
|
||
// a 2×heartbeat grace window (60s, since agents heartbeat every 30s),
|
||
// "Nm ago" / "Nh ago" / "Nd ago" otherwise.
|
||
func lastSeenLabel(t *time.Time) string {
|
||
if t == nil {
|
||
return "never"
|
||
}
|
||
return humanAgoFrom(time.Now(), *t)
|
||
}
|
||
|
||
// lastSeenClass pairs with lastSeenLabel to drive the badge color
|
||
// without the template having to carry its own logic.
|
||
func lastSeenClass(t *time.Time) string {
|
||
if t == nil {
|
||
return "offline"
|
||
}
|
||
if time.Since(*t) < 60*time.Second {
|
||
return "online"
|
||
}
|
||
return "stale"
|
||
}
|
||
|
||
// humanAgoFrom formats (now - t) as a short "Nm ago" style string.
|
||
// Buckets: <60s -> "online", <60m -> minutes, <24h -> hours, else days.
|
||
// Split on `now` so callers can hold time for tests.
|
||
func humanAgoFrom(now time.Time, t time.Time) string {
|
||
d := now.Sub(t)
|
||
if d < 0 {
|
||
d = 0
|
||
}
|
||
if d < 60*time.Second {
|
||
return "online"
|
||
}
|
||
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)))
|
||
}
|
||
|
||
var _ = templruntime.GeneratedTemplate
|