Post-repair hardware validation pipeline for Proxmox cluster hosts. Go orchestrator + in-image agent + mkosi live image + bundled dnsmasq PXE + SQLite + HTMX/SSE UI + notify registry + janitor + full docs.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"vetting/internal/model"
|
||||
)
|
||||
|
||||
// HostTile renders a single dashboard card. It's the SSE-swap target
|
||||
// for per-host tile refreshes (`tile-N`) and contains a per-run log
|
||||
// pane (`log-M`) whose live tail is appended by the events hub.
|
||||
templ HostTile(t TileData) {
|
||||
<article
|
||||
id={ fmt.Sprintf("host-%d", t.Host.ID) }
|
||||
class={ "tile", "tile-" + tileMood(t.Latest) }
|
||||
sse-swap={ fmt.Sprintf("tile-%d", t.Host.ID) }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<header class="tile-head">
|
||||
<div class="tile-name">{ t.Host.Name }</div>
|
||||
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
||||
</header>
|
||||
<dl class="tile-meta">
|
||||
<div>
|
||||
<dt>MAC</dt>
|
||||
<dd>{ t.Host.MAC }</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>WoL</dt>
|
||||
<dd>{ fmt.Sprintf("%s:%d", t.Host.WoLBroadcastIP, t.Host.WoLPort) }</dd>
|
||||
</div>
|
||||
if t.Latest != nil && t.Latest.FailedStage != "" {
|
||||
<div>
|
||||
<dt>Failed at</dt>
|
||||
<dd>{ t.Latest.FailedStage }</dd>
|
||||
</div>
|
||||
}
|
||||
if t.SpecDiffCritical > 0 {
|
||||
<div>
|
||||
<dt>Spec diffs</dt>
|
||||
<dd class="bad">{ fmt.Sprintf("%d critical", t.SpecDiffCritical) }</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
if t.Latest != nil && t.Latest.State == model.StateFailedHolding && t.Latest.HoldIP != "" {
|
||||
<div class="tile-hold">
|
||||
<div class="hold-title">Host is holding — SSH available</div>
|
||||
<code class="hold-ssh">{ sshInvocation(t.HoldKeyPath, t.Latest.HoldIP) }</code>
|
||||
</div>
|
||||
}
|
||||
if t.Latest != nil {
|
||||
<div
|
||||
class="tile-log"
|
||||
id={ fmt.Sprintf("log-%d", t.Latest.ID) }
|
||||
sse-swap={ fmt.Sprintf("log-%d", t.Latest.ID) }
|
||||
hx-swap="beforeend"
|
||||
></div>
|
||||
}
|
||||
<div class="tile-actions">
|
||||
if canStart(t.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline">
|
||||
<button type="submit">Start vetting</button>
|
||||
</form>
|
||||
} else {
|
||||
<button type="button" disabled>Run in flight</button>
|
||||
}
|
||||
if canOverrideWipe(t.Latest) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", t.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Override wipe-probe</button>
|
||||
</form>
|
||||
}
|
||||
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>
|
||||
}
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", t.Host.ID)) } class="inline">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user