Files
Vetting/internal/web/templates/host_tile.templ
T
josh 017c3c38fe
CI / Lint + build + test (push) Successful in 1m43s
Release / detect (push) Successful in 6s
Release / build-live-image (push) Has been skipped
Release / bundle (push) Successful in 52s
feat(ui): 15-point UX overhaul — affordances, feedback, and navigation
Address friction points identified in a full interface audit:
- Re-add status badge to dashboard tiles so run state is visible at a glance
- Add active nav indicator and SSE connection health monitor (live/stale)
- Show manual registration form by default instead of hiding behind <details>
- Add copy-to-clipboard buttons on SSH hold command and quick-register one-liner
- Replace tooltip-only profile descriptions with inline visible text
- Clarify non-destructive toggle with explicit stage impact description
- Replace disabled "Start vetting" button with actionable offline guidance
- Swap browser confirm() dialogs for styled inline confirmations
- Add colored badge to spec diffs summary visible when collapsed
- Add distinct "cancelled" mood for cancelled runs (vs idle)
- Add match count to log search and aria-label for accessibility
- Add styled 404 page rendered inside the app shell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 20:08:07 -04:00

167 lines
4.7 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package templates
import (
"bytes"
"context"
"fmt"
"time"
"vetting/internal/model"
)
// 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 <a>.
// It's the SSE-swap target for per-host tile refreshes (`tile-N`).
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"
>
<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">
<div class="tile-name">{ t.Host.Name }</div>
<span class={ "tile-last-seen", lastSeenClass(t.LastSeenAt) }>{ lastSeenLabel(t.LastSeenAt) }</span>
</header>
if t.Latest != nil {
<div class="tile-status">
<span class={ "run-status-badge", "run-status-badge-sm", "run-status-" + tileMood(t.Latest) }>{ tileStatus(t.Latest) }</span>
</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
}
// 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
// can walk away from it via Cancel (which reboots to local disk).
// Every other terminal state is truly done, so no Cancel button.
// The server-side CancelRun handler mirrors this predicate.
func canCancel(r *model.Run) bool {
if r == nil {
return false
}
if !r.State.IsTerminal() {
return true
}
return r.State == model.StateFailedHolding
}
func tileStatus(r *model.Run) string {
if r == nil {
return "Idle"
}
switch r.State {
case model.StateWaitingReboot:
return "Waiting for reboot"
}
if cancelledFromHold(r) {
return "Failed (cancelled)"
}
return string(r.State)
}
func tileMood(r *model.Run) string {
if r == nil {
return "idle"
}
if cancelledFromHold(r) {
return "fail"
}
switch r.State {
case model.StateCompleted:
return "pass"
case model.StateFailed, model.StateFailedHolding:
return "fail"
case model.StateCancelled:
return "cancelled"
case model.StateReleased:
return "idle"
}
return "active"
}
// cancelledFromHold is true when a FailedHolding run was later Cancelled
// by the operator (tracked by State=Cancelled with FailedStage still
// set — mid-stage cancels don't stamp FailedStage). These deserve a
// fail-colored tile because the run did fail; the cancel was just the
// operator choosing not to recover.
func cancelledFromHold(r *model.Run) bool {
return r != nil && r.State == model.StateCancelled && r.FailedStage != ""
}
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)))
}