ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.
Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.
Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -150,6 +150,19 @@ type ResultResponse struct {
|
|||||||
NextState string `json:"next_state"`
|
NextState string `json:"next_state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubStepReport is the wire shape the agent POSTs inside /result for
|
||||||
|
// each granular sub-step (CPU/Memory pass, per-disk SMART, per-device
|
||||||
|
// GPU, …). Ordinal is assigned by the server in slice order; the agent
|
||||||
|
// doesn't set it. Summary is opaque JSON the UI may render later.
|
||||||
|
type SubStepReport struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Skipped bool `json:"skipped,omitempty"`
|
||||||
|
StartedAt string `json:"started_at,omitempty"`
|
||||||
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
|
Summary json.RawMessage `json:"summary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type HoldResponse struct {
|
type HoldResponse struct {
|
||||||
AuthorizedKey string `json:"authorized_key"`
|
AuthorizedKey string `json:"authorized_key"`
|
||||||
RunID int64 `json:"run_id"`
|
RunID int64 `json:"run_id"`
|
||||||
|
|||||||
@@ -276,6 +276,25 @@ func postResult(ctx context.Context, c *Client, stage string, s stageOutcome) (*
|
|||||||
if s.Inventory != nil {
|
if s.Inventory != nil {
|
||||||
body["inventory"] = s.Inventory
|
body["inventory"] = s.Inventory
|
||||||
}
|
}
|
||||||
|
if len(s.Outcome.SubSteps) > 0 {
|
||||||
|
wire := make([]SubStepReport, 0, len(s.Outcome.SubSteps))
|
||||||
|
for _, ss := range s.Outcome.SubSteps {
|
||||||
|
w := SubStepReport{
|
||||||
|
Name: ss.Name,
|
||||||
|
Passed: ss.Passed,
|
||||||
|
Skipped: ss.Skipped,
|
||||||
|
Summary: ss.SummaryJSON,
|
||||||
|
}
|
||||||
|
if !ss.StartedAt.IsZero() {
|
||||||
|
w.StartedAt = ss.StartedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
if !ss.CompletedAt.IsZero() {
|
||||||
|
w.CompletedAt = ss.CompletedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
wire = append(wire, w)
|
||||||
|
}
|
||||||
|
body["sub_steps"] = wire
|
||||||
|
}
|
||||||
return c.Result(ctx, body)
|
return c.Result(ctx, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package tests
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -52,6 +53,7 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
|||||||
|
|
||||||
cores := runtime.NumCPU()
|
cores := runtime.NumCPU()
|
||||||
extras := map[string]any{"cores": cores}
|
extras := map[string]any{"cores": cores}
|
||||||
|
var subs []SubStepReport
|
||||||
|
|
||||||
// Pass 1: CPU
|
// Pass 1: CPU
|
||||||
cpu := runStressPass(ctx, d, "CPU", cpuPassDuration, []string{
|
cpu := runStressPass(ctx, d, "CPU", cpuPassDuration, []string{
|
||||||
@@ -62,12 +64,14 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
|||||||
"--verify",
|
"--verify",
|
||||||
})
|
})
|
||||||
extras["cpu_pass"] = cpu
|
extras["cpu_pass"] = cpu
|
||||||
|
subs = append(subs, subStepFromPass("CPU pass", cpu))
|
||||||
if !cpu.Passed {
|
if !cpu.Passed {
|
||||||
return Outcome{
|
return Outcome{
|
||||||
Passed: false,
|
Passed: false,
|
||||||
Message: "CPU pass failed: " + cpu.Err,
|
Message: "CPU pass failed: " + cpu.Err,
|
||||||
Summary: fmt.Sprintf("CPU pass failed after %ds", cpu.ElapsedSecs),
|
Summary: fmt.Sprintf("CPU pass failed after %ds", cpu.ElapsedSecs),
|
||||||
Extras: extras,
|
Extras: extras,
|
||||||
|
SubSteps: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +85,7 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
|||||||
Message: "read MemAvailable: " + err.Error(),
|
Message: "read MemAvailable: " + err.Error(),
|
||||||
Summary: "failed (meminfo unreadable)",
|
Summary: "failed (meminfo unreadable)",
|
||||||
Extras: extras,
|
Extras: extras,
|
||||||
|
SubSteps: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cap := avail - memHeadroomBytes
|
cap := avail - memHeadroomBytes
|
||||||
@@ -96,6 +101,7 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
|||||||
Message: msg,
|
Message: msg,
|
||||||
Summary: "failed (insufficient free RAM for memory pass)",
|
Summary: "failed (insufficient free RAM for memory pass)",
|
||||||
Extras: extras,
|
Extras: extras,
|
||||||
|
SubSteps: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mem := runStressPass(ctx, d, "memory", memPassDuration, []string{
|
mem := runStressPass(ctx, d, "memory", memPassDuration, []string{
|
||||||
@@ -107,12 +113,14 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
|||||||
"--verify",
|
"--verify",
|
||||||
})
|
})
|
||||||
extras["mem_pass"] = mem
|
extras["mem_pass"] = mem
|
||||||
|
subs = append(subs, subStepFromPass(fmt.Sprintf("Memory pass (cap %s)", humanBytes(cap)), mem))
|
||||||
if !mem.Passed {
|
if !mem.Passed {
|
||||||
return Outcome{
|
return Outcome{
|
||||||
Passed: false,
|
Passed: false,
|
||||||
Message: "memory pass failed: " + mem.Err,
|
Message: "memory pass failed: " + mem.Err,
|
||||||
Summary: fmt.Sprintf("memory pass failed after %ds", mem.ElapsedSecs),
|
Summary: fmt.Sprintf("memory pass failed after %ds", mem.ElapsedSecs),
|
||||||
Extras: extras,
|
Extras: extras,
|
||||||
|
SubSteps: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +129,25 @@ func CPUStress(ctx context.Context, d Deps) Outcome {
|
|||||||
Summary: fmt.Sprintf("CPU+RAM PASSED (%d cores, %s cap)",
|
Summary: fmt.Sprintf("CPU+RAM PASSED (%d cores, %s cap)",
|
||||||
cores, humanBytes(cap)),
|
cores, humanBytes(cap)),
|
||||||
Extras: extras,
|
Extras: extras,
|
||||||
|
SubSteps: subs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subStepFromPass projects a stressPass into a SubStepReport — shared by
|
||||||
|
// both passes and by the mid-stage early-return paths so the UI always
|
||||||
|
// sees exactly one row per pass, even on failure.
|
||||||
|
func subStepFromPass(name string, p stressPass) SubStepReport {
|
||||||
|
summary, _ := json.Marshal(map[string]any{
|
||||||
|
"elapsed_secs": p.ElapsedSecs,
|
||||||
|
"target_secs": p.TargetSecs,
|
||||||
|
"err": p.Err,
|
||||||
|
})
|
||||||
|
return SubStepReport{
|
||||||
|
Name: name,
|
||||||
|
Passed: p.Passed,
|
||||||
|
StartedAt: p.StartedAt,
|
||||||
|
CompletedAt: p.CompletedAt,
|
||||||
|
SummaryJSON: summary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +167,16 @@ const (
|
|||||||
|
|
||||||
// stressPass is the per-pass result embedded in CPUStress's Extras.
|
// stressPass is the per-pass result embedded in CPUStress's Extras.
|
||||||
// Passed==true and Elapsed close to target is the only happy path.
|
// Passed==true and Elapsed close to target is the only happy path.
|
||||||
|
// StartedAt/CompletedAt are not serialized (the summary already has
|
||||||
|
// ElapsedSecs) but are used by the caller to emit SubStepReport rows.
|
||||||
type stressPass struct {
|
type stressPass struct {
|
||||||
Passed bool `json:"passed"`
|
Passed bool `json:"passed"`
|
||||||
Err string `json:"err,omitempty"`
|
Err string `json:"err,omitempty"`
|
||||||
ElapsedSecs int `json:"elapsed_secs"`
|
ElapsedSecs int `json:"elapsed_secs"`
|
||||||
TargetSecs int `json:"target_secs"`
|
TargetSecs int `json:"target_secs"`
|
||||||
OutputTail string `json:"output_tail,omitempty"`
|
OutputTail string `json:"output_tail,omitempty"`
|
||||||
|
StartedAt time.Time `json:"-"`
|
||||||
|
CompletedAt time.Time `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// runStressPass invokes stress-ng and validates both exit code and
|
// runStressPass invokes stress-ng and validates both exit code and
|
||||||
@@ -159,12 +190,15 @@ func runStressPass(ctx context.Context, d Deps, label string, target time.Durati
|
|||||||
cmd := exec.CommandContext(runCtx, "stress-ng", args...)
|
cmd := exec.CommandContext(runCtx, "stress-ng", args...)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
elapsed := time.Since(start)
|
end := time.Now()
|
||||||
|
elapsed := end.Sub(start)
|
||||||
|
|
||||||
res := stressPass{
|
res := stressPass{
|
||||||
ElapsedSecs: int(elapsed.Round(time.Second).Seconds()),
|
ElapsedSecs: int(elapsed.Round(time.Second).Seconds()),
|
||||||
TargetSecs: int(target.Round(time.Second).Seconds()),
|
TargetSecs: int(target.Round(time.Second).Seconds()),
|
||||||
OutputTail: tailLines(string(out), 20),
|
OutputTail: tailLines(string(out), 20),
|
||||||
|
StartedAt: start,
|
||||||
|
CompletedAt: end,
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Err = err.Error()
|
res.Err = err.Error()
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package tests
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GPU enumerates VGA / 3D PCI devices. No devices → skip cleanly (a
|
// GPU enumerates VGA / 3D PCI devices. No devices → skip cleanly (a
|
||||||
@@ -11,7 +14,9 @@ import (
|
|||||||
// stress). Devices present → try nvidia-smi for NVIDIA cards, else
|
// stress). Devices present → try nvidia-smi for NVIDIA cards, else
|
||||||
// accept PCI presence.
|
// accept PCI presence.
|
||||||
func GPU(ctx context.Context, d Deps) Outcome {
|
func GPU(ctx context.Context, d Deps) Outcome {
|
||||||
|
pciStart := time.Now()
|
||||||
devices := listGPUPCI(ctx)
|
devices := listGPUPCI(ctx)
|
||||||
|
pciEnd := time.Now()
|
||||||
if len(devices) == 0 {
|
if len(devices) == 0 {
|
||||||
d.Info("GPU: no VGA/3D PCI devices found — skipping stage")
|
d.Info("GPU: no VGA/3D PCI devices found — skipping stage")
|
||||||
return Outcome{
|
return Outcome{
|
||||||
@@ -22,7 +27,9 @@ func GPU(ctx context.Context, d Deps) Outcome {
|
|||||||
}
|
}
|
||||||
d.Info("GPU: found " + joinDevices(devices))
|
d.Info("GPU: found " + joinDevices(devices))
|
||||||
|
|
||||||
|
nvStart := time.Now()
|
||||||
nvidia := nvidiaSmiList(ctx)
|
nvidia := nvidiaSmiList(ctx)
|
||||||
|
nvEnd := time.Now()
|
||||||
extras := map[string]any{
|
extras := map[string]any{
|
||||||
"pci_devices": devices,
|
"pci_devices": devices,
|
||||||
"skipped": false,
|
"skipped": false,
|
||||||
@@ -31,10 +38,39 @@ func GPU(ctx context.Context, d Deps) Outcome {
|
|||||||
extras["nvidia"] = nvidia
|
extras["nvidia"] = nvidia
|
||||||
d.Info("GPU: nvidia-smi reports: " + strings.Join(nvidia, ", "))
|
d.Info("GPU: nvidia-smi reports: " + strings.Join(nvidia, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sub-step rows: one per enumerated PCI device, plus (optionally) one
|
||||||
|
// per NVIDIA card when nvidia-smi sees anything. PCI enumeration runs
|
||||||
|
// once for all devices — we bracket that single invocation by
|
||||||
|
// pciStart/pciEnd and attribute the window to each device row so the
|
||||||
|
// UI can still slice the log per row by time.
|
||||||
|
var subs []SubStepReport
|
||||||
|
for i, dev := range devices {
|
||||||
|
summary, _ := json.Marshal(map[string]any{"pci": dev, "ordinal": i})
|
||||||
|
subs = append(subs, SubStepReport{
|
||||||
|
Name: fmt.Sprintf("pci #%d", i),
|
||||||
|
Passed: true,
|
||||||
|
StartedAt: pciStart,
|
||||||
|
CompletedAt: pciEnd,
|
||||||
|
SummaryJSON: summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for i, line := range nvidia {
|
||||||
|
summary, _ := json.Marshal(map[string]any{"nvidia_smi": line})
|
||||||
|
subs = append(subs, SubStepReport{
|
||||||
|
Name: fmt.Sprintf("nvidia #%d", i),
|
||||||
|
Passed: true,
|
||||||
|
StartedAt: nvStart,
|
||||||
|
CompletedAt: nvEnd,
|
||||||
|
SummaryJSON: summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return Outcome{
|
return Outcome{
|
||||||
Passed: true,
|
Passed: true,
|
||||||
Summary: formatCount(len(devices), "GPU present"),
|
Summary: formatCount(len(devices), "GPU present"),
|
||||||
Extras: extras,
|
Extras: extras,
|
||||||
|
SubSteps: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-11
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SMART runs smartctl -a on each block device the kernel exposes. We
|
// SMART runs smartctl -a on each block device the kernel exposes. We
|
||||||
@@ -46,25 +47,21 @@ func SMART(ctx context.Context, d Deps) Outcome {
|
|||||||
return Outcome{Passed: true, Summary: "skipped (no disks)", Extras: map[string]any{"skipped": true}}
|
return Outcome{Passed: true, Summary: "skipped (no disks)", Extras: map[string]any{"skipped": true}}
|
||||||
}
|
}
|
||||||
|
|
||||||
type diskReport struct {
|
var reports []smartDiskReport
|
||||||
Device string `json:"device"`
|
var subs []SubStepReport
|
||||||
Passed bool `json:"passed"`
|
|
||||||
Skipped bool `json:"skipped,omitempty"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
Raw map[string]any `json:"raw,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var reports []diskReport
|
|
||||||
failed := 0
|
failed := 0
|
||||||
usable := 0
|
usable := 0
|
||||||
for _, dev := range disks {
|
for _, dev := range disks {
|
||||||
rep := diskReport{Device: dev}
|
rep := smartDiskReport{Device: dev}
|
||||||
|
started := time.Now()
|
||||||
out, err := runSmartctl(ctx, dev)
|
out, err := runSmartctl(ctx, dev)
|
||||||
|
ended := time.Now()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rep.Skipped = true
|
rep.Skipped = true
|
||||||
rep.Reason = err.Error()
|
rep.Reason = err.Error()
|
||||||
reports = append(reports, rep)
|
reports = append(reports, rep)
|
||||||
d.Info("SMART: " + dev + " skipped (" + err.Error() + ")")
|
d.Info("SMART: " + dev + " skipped (" + err.Error() + ")")
|
||||||
|
subs = append(subs, subStepFromSMART(dev, rep, started, ended))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
usable++
|
usable++
|
||||||
@@ -82,6 +79,7 @@ func SMART(ctx context.Context, d Deps) Outcome {
|
|||||||
rep.Reason = "no smart_status in output"
|
rep.Reason = "no smart_status in output"
|
||||||
}
|
}
|
||||||
reports = append(reports, rep)
|
reports = append(reports, rep)
|
||||||
|
subs = append(subs, subStepFromSMART(dev, rep, started, ended))
|
||||||
}
|
}
|
||||||
|
|
||||||
extras := map[string]any{
|
extras := map[string]any{
|
||||||
@@ -95,6 +93,7 @@ func SMART(ctx context.Context, d Deps) Outcome {
|
|||||||
Message: fmt.Sprintf("%d disk(s) report SMART FAILED", failed),
|
Message: fmt.Sprintf("%d disk(s) report SMART FAILED", failed),
|
||||||
Summary: fmt.Sprintf("%d/%d failing", failed, usable),
|
Summary: fmt.Sprintf("%d/%d failing", failed, usable),
|
||||||
Extras: extras,
|
Extras: extras,
|
||||||
|
SubSteps: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
summary := fmt.Sprintf("%d disks, %d SMART-reporting, all PASSED", len(disks), usable)
|
summary := fmt.Sprintf("%d disks, %d SMART-reporting, all PASSED", len(disks), usable)
|
||||||
@@ -102,7 +101,36 @@ func SMART(ctx context.Context, d Deps) Outcome {
|
|||||||
summary = "skipped (no smartctl data on any disk)"
|
summary = "skipped (no smartctl data on any disk)"
|
||||||
extras["skipped"] = true
|
extras["skipped"] = true
|
||||||
}
|
}
|
||||||
return Outcome{Passed: true, Summary: summary, Extras: extras}
|
return Outcome{Passed: true, Summary: summary, Extras: extras, SubSteps: subs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// smartDiskReport is the per-disk probe result. Lifted to package scope
|
||||||
|
// so subStepFromSMART can accept it by value.
|
||||||
|
type smartDiskReport struct {
|
||||||
|
Device string `json:"device"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Skipped bool `json:"skipped,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Raw map[string]any `json:"raw,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// subStepFromSMART builds a per-disk sub-step row from the in-flight
|
||||||
|
// report. "skipped" takes precedence over passed so virtio-blk etc.
|
||||||
|
// render as skipped rather than failed in the UI.
|
||||||
|
func subStepFromSMART(dev string, rep smartDiskReport, started, ended time.Time) SubStepReport {
|
||||||
|
summary, _ := json.Marshal(map[string]any{
|
||||||
|
"device": rep.Device,
|
||||||
|
"reason": rep.Reason,
|
||||||
|
"skipped": rep.Skipped,
|
||||||
|
})
|
||||||
|
return SubStepReport{
|
||||||
|
Name: fmt.Sprintf("smartctl %s", dev),
|
||||||
|
Passed: rep.Passed || rep.Skipped,
|
||||||
|
Skipped: rep.Skipped,
|
||||||
|
StartedAt: started,
|
||||||
|
CompletedAt: ended,
|
||||||
|
SummaryJSON: summary,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func listBlockDisks() ([]string, error) {
|
func listBlockDisks() ([]string, error) {
|
||||||
|
|||||||
@@ -16,11 +16,30 @@ import (
|
|||||||
// - Message is only used on failure; the UI displays it in the log.
|
// - Message is only used on failure; the UI displays it in the log.
|
||||||
// - Extras is merged into the posted summary so stages can add
|
// - Extras is merged into the posted summary so stages can add
|
||||||
// their own shape (e.g. Storage returns per-disk probe results).
|
// their own shape (e.g. Storage returns per-disk probe results).
|
||||||
|
// - SubSteps carries agent-authored sub-step rows (CPU/Memory passes,
|
||||||
|
// per-disk SMART, per-device GPU, …). Empty for stages with no
|
||||||
|
// natural breakdown; persisted verbatim by the /result handler.
|
||||||
type Outcome struct {
|
type Outcome struct {
|
||||||
Passed bool
|
Passed bool
|
||||||
Message string
|
Message string
|
||||||
Summary string // short human-readable one-liner
|
Summary string // short human-readable one-liner
|
||||||
Extras map[string]any // merged into posted summary JSON
|
Extras map[string]any // merged into posted summary JSON
|
||||||
|
SubSteps []SubStepReport // agent-authored granular rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubStepReport is one entry a stage contributes to its sub-step list.
|
||||||
|
// Ordinal is assigned in the order entries appear in the slice — the
|
||||||
|
// agent shouldn't set it manually. State is derived from Passed/Skipped
|
||||||
|
// the same way Outcome is: Skipped wins if set, else Passed ? passed :
|
||||||
|
// failed. StartedAt/CompletedAt are required so the UI can order rows
|
||||||
|
// and slice the stage log by time window.
|
||||||
|
type SubStepReport struct {
|
||||||
|
Name string
|
||||||
|
Passed bool
|
||||||
|
Skipped bool
|
||||||
|
StartedAt time.Time
|
||||||
|
CompletedAt time.Time
|
||||||
|
SummaryJSON json.RawMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalSummary builds the summary JSON body POSTed to /result.
|
// MarshalSummary builds the summary JSON body POSTed to /result.
|
||||||
|
|||||||
@@ -91,12 +91,35 @@ func Storage(ctx context.Context, d Deps) Outcome {
|
|||||||
|
|
||||||
// Per target: short badblocks write sample + fio random-read/write.
|
// Per target: short badblocks write sample + fio random-read/write.
|
||||||
var samples []Sample
|
var samples []Sample
|
||||||
|
var subs []SubStepReport
|
||||||
perDisk := map[string]any{}
|
perDisk := map[string]any{}
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
d.Info("Storage: running badblocks write sample on " + t.Device)
|
d.Info("Storage: running badblocks write sample on " + t.Device)
|
||||||
|
bbStart := time.Now()
|
||||||
bb := runBadblocks(ctx, t.Device)
|
bb := runBadblocks(ctx, t.Device)
|
||||||
|
bbEnd := time.Now()
|
||||||
|
bbSummary, _ := json.Marshal(bb)
|
||||||
|
subs = append(subs, SubStepReport{
|
||||||
|
Name: fmt.Sprintf("badblocks %s", t.Device),
|
||||||
|
Passed: bb.OK,
|
||||||
|
StartedAt: bbStart,
|
||||||
|
CompletedAt: bbEnd,
|
||||||
|
SummaryJSON: bbSummary,
|
||||||
|
})
|
||||||
|
|
||||||
d.Info(fmt.Sprintf("Storage: running fio random rw on %s", t.Device))
|
d.Info(fmt.Sprintf("Storage: running fio random rw on %s", t.Device))
|
||||||
|
fioStart := time.Now()
|
||||||
fr := runFio(ctx, t.Device)
|
fr := runFio(ctx, t.Device)
|
||||||
|
fioEnd := time.Now()
|
||||||
|
fioSummary, _ := json.Marshal(fr)
|
||||||
|
subs = append(subs, SubStepReport{
|
||||||
|
Name: fmt.Sprintf("fio %s", t.Device),
|
||||||
|
Passed: fr.Error == "",
|
||||||
|
StartedAt: fioStart,
|
||||||
|
CompletedAt: fioEnd,
|
||||||
|
SummaryJSON: fioSummary,
|
||||||
|
})
|
||||||
|
|
||||||
perDisk[t.Device] = map[string]any{
|
perDisk[t.Device] = map[string]any{
|
||||||
"badblocks": bb,
|
"badblocks": bb,
|
||||||
"fio": fr,
|
"fio": fr,
|
||||||
@@ -111,6 +134,7 @@ func Storage(ctx context.Context, d Deps) Outcome {
|
|||||||
Message: "badblocks found errors on " + t.Device,
|
Message: "badblocks found errors on " + t.Device,
|
||||||
Summary: "badblocks failed on " + t.Device,
|
Summary: "badblocks failed on " + t.Device,
|
||||||
Extras: map[string]any{"per_disk": perDisk, "wipe_probe": probes},
|
Extras: map[string]any{"per_disk": perDisk, "wipe_probe": probes},
|
||||||
|
SubSteps: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +147,7 @@ func Storage(ctx context.Context, d Deps) Outcome {
|
|||||||
Passed: true,
|
Passed: true,
|
||||||
Summary: fmt.Sprintf("%d disks passed", len(targets)),
|
Summary: fmt.Sprintf("%d disks passed", len(targets)),
|
||||||
Extras: map[string]any{"per_disk": perDisk, "wipe_probe": probes},
|
Extras: map[string]any{"per_disk": perDisk, "wipe_probe": probes},
|
||||||
|
SubSteps: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -56,6 +56,7 @@ func main() {
|
|||||||
hostStore := &store.Hosts{DB: conn}
|
hostStore := &store.Hosts{DB: conn}
|
||||||
runStore := &store.Runs{DB: conn}
|
runStore := &store.Runs{DB: conn}
|
||||||
stageStore := &store.Stages{DB: conn}
|
stageStore := &store.Stages{DB: conn}
|
||||||
|
subStepStore := &store.SubSteps{DB: conn}
|
||||||
artifactStore := &store.Artifacts{DB: conn}
|
artifactStore := &store.Artifacts{DB: conn}
|
||||||
specDiffStore := &store.SpecDiffs{DB: conn}
|
specDiffStore := &store.SpecDiffs{DB: conn}
|
||||||
measurementStore := &store.Measurements{DB: conn}
|
measurementStore := &store.Measurements{DB: conn}
|
||||||
@@ -77,6 +78,7 @@ func main() {
|
|||||||
|
|
||||||
tiles := &api.TileEnricher{
|
tiles := &api.TileEnricher{
|
||||||
Runs: runStore,
|
Runs: runStore,
|
||||||
|
Stages: stageStore,
|
||||||
Artifacts: artifactStore,
|
Artifacts: artifactStore,
|
||||||
SpecDiffs: specDiffStore,
|
SpecDiffs: specDiffStore,
|
||||||
}
|
}
|
||||||
@@ -90,6 +92,7 @@ func main() {
|
|||||||
return templates.RenderTileString(tiles.Build(ctx, host, latest))
|
return templates.RenderTileString(tiles.Build(ctx, host, latest))
|
||||||
}
|
}
|
||||||
orchestrator.PipelineRenderer = templates.RenderPipelineString
|
orchestrator.PipelineRenderer = templates.RenderPipelineString
|
||||||
|
orchestrator.SubStepRenderer = templates.RenderSubStepRowString
|
||||||
|
|
||||||
notifyReg, err := notify.BuildRegistry(cfg.Notifiers, cfg.Routes)
|
notifyReg, err := notify.BuildRegistry(cfg.Notifiers, cfg.Routes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,6 +103,7 @@ func main() {
|
|||||||
Hosts: hostStore,
|
Hosts: hostStore,
|
||||||
Runs: runStore,
|
Runs: runStore,
|
||||||
Stages: stageStore,
|
Stages: stageStore,
|
||||||
|
SubSteps: subStepStore,
|
||||||
SpecDiffs: specDiffStore,
|
SpecDiffs: specDiffStore,
|
||||||
Artifacts: artifactStore,
|
Artifacts: artifactStore,
|
||||||
EventHub: hub,
|
EventHub: hub,
|
||||||
@@ -114,7 +118,10 @@ func main() {
|
|||||||
// reload-rendered page byte-for-byte, then hands each region to
|
// reload-rendered page byte-for-byte, then hands each region to
|
||||||
// its Render*String helper.
|
// its Render*String helper.
|
||||||
orchestrator.HostDetailRenderer = func(ctx context.Context, hostID int64) (orchestrator.HostDetailFragments, bool) {
|
orchestrator.HostDetailRenderer = func(ctx context.Context, hostID int64) (orchestrator.HostDetailFragments, bool) {
|
||||||
d, err := ui.LoadHostDetailData(ctx, hostID)
|
// Orchestrator-side publishes always reference the latest run —
|
||||||
|
// SSE topics are keyed by runID, so a stale ?run=N bookmark
|
||||||
|
// doesn't affect what the server pushes.
|
||||||
|
d, err := ui.LoadHostDetailData(ctx, hostID, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return orchestrator.HostDetailFragments{}, false
|
return orchestrator.HostDetailFragments{}, false
|
||||||
}
|
}
|
||||||
@@ -134,6 +141,7 @@ func main() {
|
|||||||
Hosts: hostStore,
|
Hosts: hostStore,
|
||||||
Runs: runStore,
|
Runs: runStore,
|
||||||
Stages: stageStore,
|
Stages: stageStore,
|
||||||
|
SubSteps: subStepStore,
|
||||||
Artifacts: artifactStore,
|
Artifacts: artifactStore,
|
||||||
SpecDiffs: specDiffStore,
|
SpecDiffs: specDiffStore,
|
||||||
Measurements: measurementStore,
|
Measurements: measurementStore,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type Agent struct {
|
|||||||
Hosts *store.Hosts
|
Hosts *store.Hosts
|
||||||
Runs *store.Runs
|
Runs *store.Runs
|
||||||
Stages *store.Stages
|
Stages *store.Stages
|
||||||
|
SubSteps *store.SubSteps
|
||||||
Artifacts *store.Artifacts
|
Artifacts *store.Artifacts
|
||||||
SpecDiffs *store.SpecDiffs
|
SpecDiffs *store.SpecDiffs
|
||||||
Measurements *store.Measurements
|
Measurements *store.Measurements
|
||||||
@@ -386,12 +387,30 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
|
|||||||
// DefaultStageOrder); Passed drives StageCompleted vs StageFailed.
|
// DefaultStageOrder); Passed drives StageCompleted vs StageFailed.
|
||||||
// Inventory is optional and only set when kind == "Inventory" — the
|
// Inventory is optional and only set when kind == "Inventory" — the
|
||||||
// orchestrator persists it as an artifact and feeds it to spec.Diff.
|
// orchestrator persists it as an artifact and feeds it to spec.Diff.
|
||||||
|
//
|
||||||
|
// SubSteps is agent-authored granular rows (CPU/Memory pass, per-disk
|
||||||
|
// SMART, per-device GPU, …). Empty for stages with no natural
|
||||||
|
// breakdown. Persisted after the mismatch guard fires; per-row SSE is
|
||||||
|
// emitted at the same time so the detail pane can surface them without
|
||||||
|
// a full page reload.
|
||||||
type StageResult struct {
|
type StageResult struct {
|
||||||
Stage string `json:"stage"`
|
Stage string `json:"stage"`
|
||||||
Passed bool `json:"passed"`
|
Passed bool `json:"passed"`
|
||||||
Summary json.RawMessage `json:"summary,omitempty"`
|
Summary json.RawMessage `json:"summary,omitempty"`
|
||||||
Inventory *spec.Inventory `json:"inventory,omitempty"`
|
Inventory *spec.Inventory `json:"inventory,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
|
SubSteps []SubStepResultLine `json:"sub_steps,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubStepResultLine is one entry in StageResult.SubSteps. Ordinal is
|
||||||
|
// assigned from slice index server-side; the agent doesn't set it.
|
||||||
|
type SubStepResultLine struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Skipped bool `json:"skipped,omitempty"`
|
||||||
|
StartedAt string `json:"started_at,omitempty"`
|
||||||
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
|
Summary json.RawMessage `json:"summary,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result receives a stage's outcome. Flow:
|
// Result receives a stage's outcome. Flow:
|
||||||
@@ -470,6 +489,12 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent-authored sub-steps: persist in slice order (ordinal = index)
|
||||||
|
// and fan out a per-row SSE event each so the detail pane shows them
|
||||||
|
// without a reload. Best-effort — a persistence error is logged but
|
||||||
|
// doesn't fail the whole /result.
|
||||||
|
a.persistSubSteps(r.Context(), runID, body.Stage, body.SubSteps)
|
||||||
|
|
||||||
// Inventory-specific: persist artifact + compute spec diff.
|
// Inventory-specific: persist artifact + compute spec diff.
|
||||||
if body.Stage == "Inventory" && body.Inventory != nil {
|
if body.Stage == "Inventory" && body.Inventory != nil {
|
||||||
if err := a.persistInventory(r, run, body.Inventory); err != nil {
|
if err := a.persistInventory(r, run, body.Inventory); err != nil {
|
||||||
@@ -531,6 +556,65 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": string(next)})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "next_state": string(next)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// persistSubSteps writes each reported sub-step as a row keyed by
|
||||||
|
// (runID, stage, ordinal) where ordinal is the slice index, then emits
|
||||||
|
// a per-row SSE event so an open detail page updates without a reload.
|
||||||
|
// Silently no-ops when SubSteps is unwired (tests that don't supply a
|
||||||
|
// store) or the slice is empty.
|
||||||
|
func (a *Agent) persistSubSteps(ctx context.Context, runID int64, stage string, lines []SubStepResultLine) {
|
||||||
|
if a.SubSteps == nil || len(lines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, line := range lines {
|
||||||
|
state := model.StagePassed
|
||||||
|
switch {
|
||||||
|
case line.Skipped:
|
||||||
|
state = model.StageSkipped
|
||||||
|
case !line.Passed:
|
||||||
|
state = model.StageFailed
|
||||||
|
}
|
||||||
|
started := parseResultTime(line.StartedAt)
|
||||||
|
completed := parseResultTime(line.CompletedAt)
|
||||||
|
summaryJSON := ""
|
||||||
|
if len(line.Summary) > 0 {
|
||||||
|
summaryJSON = string(line.Summary)
|
||||||
|
}
|
||||||
|
ss := model.SubStep{
|
||||||
|
RunID: runID,
|
||||||
|
StageName: stage,
|
||||||
|
Ordinal: i,
|
||||||
|
Name: line.Name,
|
||||||
|
State: state,
|
||||||
|
StartedAt: started,
|
||||||
|
CompletedAt: completed,
|
||||||
|
SummaryJSON: summaryJSON,
|
||||||
|
}
|
||||||
|
if err := a.SubSteps.Upsert(ctx, ss); err != nil {
|
||||||
|
log.Printf("substep upsert run=%d stage=%s ord=%d: %v", runID, stage, i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.Runner != nil {
|
||||||
|
a.Runner.PublishSubStepUpdate(ctx, ss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseResultTime tolerates RFC3339 / RFC3339Nano and returns nil for
|
||||||
|
// empty or unparseable values so a missing timestamp doesn't block the
|
||||||
|
// persist path.
|
||||||
|
func parseResultTime(s string) *time.Time {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) persistInventory(r *http.Request, run *model.Run, inv *spec.Inventory) error {
|
func (a *Agent) persistInventory(r *http.Request, run *model.Run, inv *spec.Inventory) error {
|
||||||
dir := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", run.ID))
|
dir := filepath.Join(a.ArtifactsDir, fmt.Sprintf("run-%d", run.ID))
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) {
|
|||||||
hosts := &store.Hosts{DB: conn}
|
hosts := &store.Hosts{DB: conn}
|
||||||
runs := &store.Runs{DB: conn}
|
runs := &store.Runs{DB: conn}
|
||||||
meas := &store.Measurements{DB: conn}
|
meas := &store.Measurements{DB: conn}
|
||||||
|
subSteps := &store.SubSteps{DB: conn}
|
||||||
|
|
||||||
hostID, err := hosts.Create(context.Background(), model.Host{
|
hostID, err := hosts.Create(context.Background(), model.Host{
|
||||||
Name: "t-host",
|
Name: "t-host",
|
||||||
@@ -55,6 +57,7 @@ func setupAgent(t *testing.T) (*api.Agent, int64, string) {
|
|||||||
Hosts: hosts,
|
Hosts: hosts,
|
||||||
Runs: runs,
|
Runs: runs,
|
||||||
Measurements: meas,
|
Measurements: meas,
|
||||||
|
SubSteps: subSteps,
|
||||||
}, runID, plain
|
}, runID, plain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,3 +218,73 @@ func TestResult_AcceptsMatchingStage(t *testing.T) {
|
|||||||
t.Fatalf("run state = %q, want CPUStress after SMART pass", after.State)
|
t.Fatalf("run state = %q, want CPUStress after SMART pass", after.State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResult_PersistsSubSteps covers the /result handler's contract for
|
||||||
|
// the new sub_steps table: when the agent includes a sub_steps array in
|
||||||
|
// the POST body, each entry lands in the table with an ordinal equal to
|
||||||
|
// its slice index, state derived from passed/skipped, and timestamps
|
||||||
|
// parsed from RFC3339. The guard must let the call through (matching
|
||||||
|
// stage) and sub-steps are written *after* CompleteStage so a persistence
|
||||||
|
// error doesn't wedge the whole run.
|
||||||
|
func TestResult_PersistsSubSteps(t *testing.T) {
|
||||||
|
a, runID, token := setupAgent(t)
|
||||||
|
a.Runner = &orchestrator.Runner{Runs: a.Runs, Hosts: a.Hosts, Stages: &store.Stages{DB: a.Runs.DB}, EventHub: events.NewHub()}
|
||||||
|
stages := &store.Stages{DB: a.Runs.DB}
|
||||||
|
if err := stages.Seed(context.Background(), runID); err != nil {
|
||||||
|
t.Fatalf("seed stages: %v", err)
|
||||||
|
}
|
||||||
|
if err := a.Runs.SetState(context.Background(), runID, model.StateCPUStress); err != nil {
|
||||||
|
t.Fatalf("set state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Date(2026, 4, 18, 13, 0, 0, 0, time.UTC)
|
||||||
|
end := start.Add(3 * time.Minute)
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"stage": "CPUStress",
|
||||||
|
"passed": true,
|
||||||
|
"sub_steps": []map[string]any{
|
||||||
|
{
|
||||||
|
"name": "CPU pass",
|
||||||
|
"passed": true,
|
||||||
|
"started_at": start.Format(time.RFC3339Nano),
|
||||||
|
"completed_at": end.Format(time.RFC3339Nano),
|
||||||
|
"summary": json.RawMessage(`{"elapsed_secs":180}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Memory pass",
|
||||||
|
"passed": false,
|
||||||
|
"started_at": end.Format(time.RFC3339Nano),
|
||||||
|
"completed_at": end.Add(2 * time.Minute).Format(time.RFC3339Nano),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
req := routedRequest(runID, http.MethodPost, "/api/v1/runs/"+strconv.FormatInt(runID, 10)+"/result", body)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
a.Result(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := a.SubSteps.ListForRun(context.Background(), runID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListForRun: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("got %d sub-steps, want 2", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Ordinal != 0 || rows[0].Name != "CPU pass" || rows[0].State != model.StagePassed {
|
||||||
|
t.Fatalf("row[0] = %+v", rows[0])
|
||||||
|
}
|
||||||
|
if rows[1].Ordinal != 1 || rows[1].Name != "Memory pass" || rows[1].State != model.StageFailed {
|
||||||
|
t.Fatalf("row[1] = %+v", rows[1])
|
||||||
|
}
|
||||||
|
if rows[0].StartedAt == nil || !rows[0].StartedAt.Equal(start) {
|
||||||
|
t.Fatalf("row[0].StartedAt = %v, want %v", rows[0].StartedAt, start)
|
||||||
|
}
|
||||||
|
if rows[0].SummaryJSON != `{"elapsed_secs":180}` {
|
||||||
|
t.Fatalf("row[0].SummaryJSON = %q", rows[0].SummaryJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"vetting/internal/api"
|
"vetting/internal/api"
|
||||||
"vetting/internal/db"
|
"vetting/internal/db"
|
||||||
"vetting/internal/events"
|
"vetting/internal/events"
|
||||||
|
"vetting/internal/logs"
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
"vetting/internal/orchestrator"
|
"vetting/internal/orchestrator"
|
||||||
"vetting/internal/store"
|
"vetting/internal/store"
|
||||||
@@ -21,7 +24,8 @@ import (
|
|||||||
|
|
||||||
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
tmp := t.TempDir()
|
||||||
|
conn, err := db.Open(filepath.Join(tmp, "vetting.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open db: %v", err)
|
t.Fatalf("open db: %v", err)
|
||||||
}
|
}
|
||||||
@@ -29,18 +33,26 @@ func setupDetail(t *testing.T) (*api.UI, *store.Hosts, *store.Runs) {
|
|||||||
hosts := &store.Hosts{DB: conn}
|
hosts := &store.Hosts{DB: conn}
|
||||||
runs := &store.Runs{DB: conn}
|
runs := &store.Runs{DB: conn}
|
||||||
stages := &store.Stages{DB: conn}
|
stages := &store.Stages{DB: conn}
|
||||||
|
subSteps := &store.SubSteps{DB: conn}
|
||||||
diffs := &store.SpecDiffs{DB: conn}
|
diffs := &store.SpecDiffs{DB: conn}
|
||||||
arts := &store.Artifacts{DB: conn}
|
arts := &store.Artifacts{DB: conn}
|
||||||
hub := events.NewHub()
|
hub := events.NewHub()
|
||||||
|
logsHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logs hub: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(logsHub.Close)
|
||||||
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
||||||
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
|
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
|
||||||
ui := &api.UI{
|
ui := &api.UI{
|
||||||
Hosts: hosts,
|
Hosts: hosts,
|
||||||
Runs: runs,
|
Runs: runs,
|
||||||
Stages: stages,
|
Stages: stages,
|
||||||
|
SubSteps: subSteps,
|
||||||
SpecDiffs: diffs,
|
SpecDiffs: diffs,
|
||||||
Artifacts: arts,
|
Artifacts: arts,
|
||||||
EventHub: hub,
|
EventHub: hub,
|
||||||
|
Logs: logsHub,
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
Tiles: tiles,
|
Tiles: tiles,
|
||||||
}
|
}
|
||||||
@@ -54,6 +66,16 @@ func detailReq(id int64) *http.Request {
|
|||||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detailReqWithQuery is detailReq with an optional ?run= query string.
|
||||||
|
// Used by TestHostDetail_RunQueryParam so we can drive the selected-run
|
||||||
|
// branch without routing through the real router.
|
||||||
|
func detailReqWithQuery(id int64, rawQuery string) *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/hosts/%d?%s", id, rawQuery), nil)
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add("id", fmt.Sprintf("%d", id))
|
||||||
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
||||||
|
}
|
||||||
|
|
||||||
func TestHostDetail_OK(t *testing.T) {
|
func TestHostDetail_OK(t *testing.T) {
|
||||||
ui, hosts, runs := setupDetail(t)
|
ui, hosts, runs := setupDetail(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -71,6 +93,9 @@ func TestHostDetail_OK(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create run: %v", err)
|
t.Fatalf("create run: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||||
|
t.Fatalf("seed stages: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
ui.HostDetail(rr, detailReq(id))
|
ui.HostDetail(rr, detailReq(id))
|
||||||
@@ -85,7 +110,8 @@ func TestHostDetail_OK(t *testing.T) {
|
|||||||
if !strings.Contains(body, wantPipelineID) {
|
if !strings.Contains(body, wantPipelineID) {
|
||||||
t.Fatalf("body missing %s", wantPipelineID)
|
t.Fatalf("body missing %s", wantPipelineID)
|
||||||
}
|
}
|
||||||
wantLogID := fmt.Sprintf(`id="log-%d"`, runID)
|
// Each stage owns its own log pane; assert one of them is present.
|
||||||
|
wantLogID := fmt.Sprintf(`id="log-%d-Inventory"`, runID)
|
||||||
if !strings.Contains(body, wantLogID) {
|
if !strings.Contains(body, wantLogID) {
|
||||||
t.Fatalf("body missing %s", wantLogID)
|
t.Fatalf("body missing %s", wantLogID)
|
||||||
}
|
}
|
||||||
@@ -121,14 +147,16 @@ func TestHostDetail_NeverRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHostDetail_LogTabsRendered: when a run exists, the detail page
|
// TestHostDetail_ActiveStepsRendered: every canonical stage gets its own
|
||||||
// emits the log-tabs scaffold with one radio per stage + an "All" tab
|
// <details data-stage="..."> panel with a matching log pane id, replacing
|
||||||
// checked by default. CSS sibling selectors drive visibility — no JS.
|
// the old flat log-tab scaffold. Also confirms the sub-step SSE swap
|
||||||
func TestHostDetail_LogTabsRendered(t *testing.T) {
|
// target exists when sub-steps are seeded for a stage (so Phase 1's
|
||||||
|
// substep-* event path has a DOM home).
|
||||||
|
func TestHostDetail_ActiveStepsRendered(t *testing.T) {
|
||||||
ui, hosts, runs := setupDetail(t)
|
ui, hosts, runs := setupDetail(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
id, err := hosts.Create(ctx, model.Host{
|
id, err := hosts.Create(ctx, model.Host{
|
||||||
Name: "tabs-host",
|
Name: "steps-host",
|
||||||
MAC: "aa:bb:cc:dd:ee:40",
|
MAC: "aa:bb:cc:dd:ee:40",
|
||||||
WoLBroadcastIP: "10.0.0.255",
|
WoLBroadcastIP: "10.0.0.255",
|
||||||
WoLPort: 9,
|
WoLPort: 9,
|
||||||
@@ -141,6 +169,19 @@ func TestHostDetail_LogTabsRendered(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create run: %v", err)
|
t.Fatalf("create run: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||||
|
t.Fatalf("seed stages: %v", err)
|
||||||
|
}
|
||||||
|
// Seed one CPUStress sub-step so the SubStepRow swap target lands.
|
||||||
|
if err := ui.SubSteps.Upsert(ctx, model.SubStep{
|
||||||
|
RunID: runID,
|
||||||
|
StageName: "CPUStress",
|
||||||
|
Ordinal: 0,
|
||||||
|
Name: "CPU pass",
|
||||||
|
State: model.StagePending,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert sub-step: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
ui.HostDetail(rr, detailReq(id))
|
ui.HostDetail(rr, detailReq(id))
|
||||||
@@ -149,23 +190,246 @@ func TestHostDetail_LogTabsRendered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body := rr.Body.String()
|
body := rr.Body.String()
|
||||||
|
|
||||||
// All tab: the default-checked radio, plus its pane.
|
// Every stage in DefaultStageOrder owns a collapsible panel + log pane.
|
||||||
wantAllID := fmt.Sprintf(`id="log-tab-%d-all"`, runID)
|
|
||||||
if !strings.Contains(body, wantAllID) {
|
|
||||||
t.Fatalf("body missing All tab radio %s", wantAllID)
|
|
||||||
}
|
|
||||||
// Per-stage tabs: every entry in DefaultStageOrder must have its own
|
|
||||||
// radio + pane so tabs switch purely via sibling CSS.
|
|
||||||
for _, s := range store.DefaultStageOrder {
|
for _, s := range store.DefaultStageOrder {
|
||||||
wantRadio := fmt.Sprintf(`id="log-tab-%d-%s"`, runID, s)
|
wantPanel := fmt.Sprintf(`data-stage="%s"`, s)
|
||||||
if !strings.Contains(body, wantRadio) {
|
if !strings.Contains(body, wantPanel) {
|
||||||
t.Fatalf("body missing stage tab radio %s", wantRadio)
|
t.Fatalf("body missing active-step panel %s", wantPanel)
|
||||||
}
|
}
|
||||||
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
|
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
|
||||||
if !strings.Contains(body, wantPane) {
|
if !strings.Contains(body, wantPane) {
|
||||||
t.Fatalf("body missing stage pane %s", wantPane)
|
t.Fatalf("body missing stage log pane %s", wantPane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sub-step row for CPUStress/0 is present and SSE-bound.
|
||||||
|
wantSub := fmt.Sprintf(`id="substep-%d-CPUStress-0"`, runID)
|
||||||
|
if !strings.Contains(body, wantSub) {
|
||||||
|
t.Fatalf("body missing sub-step row %s", wantSub)
|
||||||
|
}
|
||||||
|
wantSubSwap := fmt.Sprintf(`sse-swap="substep-%d-CPUStress-0"`, runID)
|
||||||
|
if !strings.Contains(body, wantSubSwap) {
|
||||||
|
t.Fatalf("body missing sub-step sse-swap %s", wantSubSwap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultOpenStage returns the value of data-stage on the single
|
||||||
|
// `<details ... open data-stage="...">` emitted by ActiveStep. Returns
|
||||||
|
// "" if no stage is currently open. The rendered attribute order is
|
||||||
|
// fixed by active_step.templ (class, then open?, then data-stage), so
|
||||||
|
// a tight substring match is safe.
|
||||||
|
func defaultOpenStage(body string) string {
|
||||||
|
re := regexp.MustCompile(`open data-stage="([^"]+)"`)
|
||||||
|
m := re.FindStringSubmatch(body)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostDetail_DefaultStep_Running(t *testing.T) {
|
||||||
|
ui, hosts, runs := setupDetail(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := hosts.Create(ctx, model.Host{
|
||||||
|
Name: "default-running",
|
||||||
|
MAC: "aa:bb:cc:dd:ee:50",
|
||||||
|
WoLBroadcastIP: "10.0.0.255",
|
||||||
|
WoLPort: 9,
|
||||||
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host: %v", err)
|
||||||
|
}
|
||||||
|
runID, err := runs.Create(ctx, id, "t-running", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create run: %v", err)
|
||||||
|
}
|
||||||
|
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||||
|
t.Fatalf("seed stages: %v", err)
|
||||||
|
}
|
||||||
|
// Earlier stages passed; SMART is running.
|
||||||
|
for _, name := range []string{"Inventory", "SpecValidate"} {
|
||||||
|
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
|
||||||
|
t.Fatalf("start %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
|
||||||
|
t.Fatalf("complete %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ui.Stages.StartByName(ctx, runID, "SMART"); err != nil {
|
||||||
|
t.Fatalf("start SMART: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReq(id))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if got := defaultOpenStage(rr.Body.String()); got != "SMART" {
|
||||||
|
t.Fatalf("default step = %q, want SMART", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostDetail_DefaultStep_Failed(t *testing.T) {
|
||||||
|
ui, hosts, runs := setupDetail(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := hosts.Create(ctx, model.Host{
|
||||||
|
Name: "default-failed",
|
||||||
|
MAC: "aa:bb:cc:dd:ee:51",
|
||||||
|
WoLBroadcastIP: "10.0.0.255",
|
||||||
|
WoLPort: 9,
|
||||||
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host: %v", err)
|
||||||
|
}
|
||||||
|
runID, err := runs.Create(ctx, id, "t-failed", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create run: %v", err)
|
||||||
|
}
|
||||||
|
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||||
|
t.Fatalf("seed stages: %v", err)
|
||||||
|
}
|
||||||
|
// Inventory + SpecValidate + SMART passed; CPUStress failed; nothing
|
||||||
|
// running. Default must land on CPUStress.
|
||||||
|
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
|
||||||
|
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
|
||||||
|
t.Fatalf("start %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
|
||||||
|
t.Fatalf("complete %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := ui.Stages.StartByName(ctx, runID, "CPUStress"); err != nil {
|
||||||
|
t.Fatalf("start CPUStress: %v", err)
|
||||||
|
}
|
||||||
|
if err := ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`); err != nil {
|
||||||
|
t.Fatalf("complete CPUStress: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReq(id))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" {
|
||||||
|
t.Fatalf("default step = %q, want CPUStress", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostDetail_DefaultStep_Completed(t *testing.T) {
|
||||||
|
ui, hosts, runs := setupDetail(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := hosts.Create(ctx, model.Host{
|
||||||
|
Name: "default-done",
|
||||||
|
MAC: "aa:bb:cc:dd:ee:52",
|
||||||
|
WoLBroadcastIP: "10.0.0.255",
|
||||||
|
WoLPort: 9,
|
||||||
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host: %v", err)
|
||||||
|
}
|
||||||
|
runID, err := runs.Create(ctx, id, "t-done", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create run: %v", err)
|
||||||
|
}
|
||||||
|
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
||||||
|
t.Fatalf("seed stages: %v", err)
|
||||||
|
}
|
||||||
|
// All stages passed → default lands on Reporting.
|
||||||
|
for _, name := range store.DefaultStageOrder {
|
||||||
|
if err := ui.Stages.StartByName(ctx, runID, name); err != nil {
|
||||||
|
t.Fatalf("start %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, ""); err != nil {
|
||||||
|
t.Fatalf("complete %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReq(id))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||||
|
t.Fatalf("default step = %q, want Reporting", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostDetail_RunQueryParam: ?run=N selects a specific past run
|
||||||
|
// instead of the latest. The history sidebar's links rely on this.
|
||||||
|
func TestHostDetail_RunQueryParam(t *testing.T) {
|
||||||
|
ui, hosts, runs := setupDetail(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := hosts.Create(ctx, model.Host{
|
||||||
|
Name: "query-run",
|
||||||
|
MAC: "aa:bb:cc:dd:ee:53",
|
||||||
|
WoLBroadcastIP: "10.0.0.255",
|
||||||
|
WoLPort: 9,
|
||||||
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host: %v", err)
|
||||||
|
}
|
||||||
|
// Older run: failed at CPUStress. Newer run: fully passed.
|
||||||
|
oldRun, err := runs.Create(ctx, id, "old", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create old run: %v", err)
|
||||||
|
}
|
||||||
|
if err := ui.Stages.Seed(ctx, oldRun); err != nil {
|
||||||
|
t.Fatalf("seed old: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
|
||||||
|
_ = ui.Stages.StartByName(ctx, oldRun, name)
|
||||||
|
_ = ui.Stages.CompleteByName(ctx, oldRun, name, model.StagePassed, "")
|
||||||
|
}
|
||||||
|
_ = ui.Stages.StartByName(ctx, oldRun, "CPUStress")
|
||||||
|
_ = ui.Stages.CompleteByName(ctx, oldRun, "CPUStress", model.StageFailed, "")
|
||||||
|
|
||||||
|
// Newer run lands after a tiny gap so Runs.LatestForHost picks it.
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
newRun, err := runs.Create(ctx, id, "new", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create new run: %v", err)
|
||||||
|
}
|
||||||
|
if err := ui.Stages.Seed(ctx, newRun); err != nil {
|
||||||
|
t.Fatalf("seed new: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range store.DefaultStageOrder {
|
||||||
|
_ = ui.Stages.StartByName(ctx, newRun, name)
|
||||||
|
_ = ui.Stages.CompleteByName(ctx, newRun, name, model.StagePassed, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: with no ?run=, default is Reporting (latest run is green).
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReq(id))
|
||||||
|
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||||
|
t.Fatalf("latest default = %q, want Reporting", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With ?run={old}, we view the failed run → default is CPUStress and
|
||||||
|
// the pipeline section references the old run's ID.
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReqWithQuery(id, fmt.Sprintf("run=%d", oldRun)))
|
||||||
|
body := rr.Body.String()
|
||||||
|
if got := defaultOpenStage(body); got != "CPUStress" {
|
||||||
|
t.Fatalf("?run=old default = %q, want CPUStress", got)
|
||||||
|
}
|
||||||
|
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, oldRun)
|
||||||
|
if !strings.Contains(body, wantPipelineID) {
|
||||||
|
t.Fatalf("?run=old body missing %s", wantPipelineID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ?run= value that belongs to no host at all falls back to latest
|
||||||
|
// silently (stale bookmark should never 4xx).
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReqWithQuery(id, "run=9999"))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bogus run fallback status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
||||||
|
t.Fatalf("bogus run default = %q, want Reporting (fall back to latest)", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostDetail_UnknownID(t *testing.T) {
|
func TestHostDetail_UnknownID(t *testing.T) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
// place that renders a tile shows the same data.
|
// place that renders a tile shows the same data.
|
||||||
type TileEnricher struct {
|
type TileEnricher struct {
|
||||||
Runs *store.Runs
|
Runs *store.Runs
|
||||||
|
Stages *store.Stages
|
||||||
Artifacts *store.Artifacts
|
Artifacts *store.Artifacts
|
||||||
SpecDiffs *store.SpecDiffs
|
SpecDiffs *store.SpecDiffs
|
||||||
}
|
}
|
||||||
@@ -53,6 +54,16 @@ func (e *TileEnricher) Build(ctx context.Context, host model.Host, latest *model
|
|||||||
log.Printf("tile: list artifacts run %d: %v", latest.ID, err)
|
log.Printf("tile: list artifacts run %d: %v", latest.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Stage row per canonical stage drives the dashboard tile's mini
|
||||||
|
// run-view strip. Fail-soft: a DB hiccup renders the tile without
|
||||||
|
// dots rather than breaking the whole dashboard.
|
||||||
|
if e.Stages != nil {
|
||||||
|
if stages, err := e.Stages.ListForRun(ctx, latest.ID); err == nil {
|
||||||
|
t.Stages = stages
|
||||||
|
} else {
|
||||||
|
log.Printf("tile: list stages run %d: %v", latest.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type UI struct {
|
|||||||
Hosts *store.Hosts
|
Hosts *store.Hosts
|
||||||
Runs *store.Runs
|
Runs *store.Runs
|
||||||
Stages *store.Stages
|
Stages *store.Stages
|
||||||
|
SubSteps *store.SubSteps
|
||||||
SpecDiffs *store.SpecDiffs
|
SpecDiffs *store.SpecDiffs
|
||||||
Artifacts *store.Artifacts
|
Artifacts *store.Artifacts
|
||||||
EventHub *events.Hub
|
EventHub *events.Hub
|
||||||
@@ -118,7 +119,16 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "bad host id", http.StatusBadRequest)
|
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := u.LoadHostDetailData(r.Context(), id)
|
// Optional ?run=N: select a specific past run instead of the latest.
|
||||||
|
// Rejected runs (bad parse, wrong host) fall back to latest silently
|
||||||
|
// so a stale bookmark doesn't 404.
|
||||||
|
var selectedRunID int64
|
||||||
|
if q := r.URL.Query().Get("run"); q != "" {
|
||||||
|
if parsed, err := strconv.ParseInt(q, 10, 64); err == nil {
|
||||||
|
selectedRunID = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := u.LoadHostDetailData(r.Context(), id, selectedRunID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -139,7 +149,12 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
// diffs, replay, and tile enrichment are fail-soft (empty on error) —
|
// diffs, replay, and tile enrichment are fail-soft (empty on error) —
|
||||||
// mirrors the original inline behaviour so a transient DB hiccup on one
|
// mirrors the original inline behaviour so a transient DB hiccup on one
|
||||||
// relation doesn't blank the whole page.
|
// relation doesn't blank the whole page.
|
||||||
func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.HostDetailData, error) {
|
//
|
||||||
|
// selectedRunID == 0 means "use the latest run". A positive value picks
|
||||||
|
// a specific past run for the hosts/{id}?run=N history-sidebar navigation;
|
||||||
|
// if that run doesn't exist or belongs to another host we fall back to
|
||||||
|
// the latest so a stale URL doesn't error out.
|
||||||
|
func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64, selectedRunID int64) (templates.HostDetailData, error) {
|
||||||
host, err := u.Hosts.Get(ctx, hostID)
|
host, err := u.Hosts.Get(ctx, hostID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return templates.HostDetailData{}, err
|
return templates.HostDetailData{}, err
|
||||||
@@ -148,29 +163,74 @@ func (u *UI) LoadHostDetailData(ctx context.Context, hostID int64) (templates.Ho
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return templates.HostDetailData{}, err
|
return templates.HostDetailData{}, err
|
||||||
}
|
}
|
||||||
|
// Resolve the viewed run: selectedRunID wins when it matches this
|
||||||
|
// host; otherwise fall back to latest. A run that belongs to a
|
||||||
|
// different host is silently ignored — no operator action should be
|
||||||
|
// able to render another host's run under this page.
|
||||||
|
viewed := latest
|
||||||
|
if selectedRunID > 0 && u.Runs != nil {
|
||||||
|
if r, err := u.Runs.Get(ctx, selectedRunID); err == nil && r != nil && r.HostID == hostID {
|
||||||
|
viewed = r
|
||||||
|
}
|
||||||
|
}
|
||||||
var stages []model.Stage
|
var stages []model.Stage
|
||||||
var diffs []model.SpecDiff
|
var diffs []model.SpecDiff
|
||||||
if latest != nil {
|
var subSteps []model.SubStep
|
||||||
|
if viewed != nil {
|
||||||
if u.Stages != nil {
|
if u.Stages != nil {
|
||||||
stages, _ = u.Stages.ListForRun(ctx, latest.ID)
|
stages, _ = u.Stages.ListForRun(ctx, viewed.ID)
|
||||||
}
|
}
|
||||||
if u.SpecDiffs != nil {
|
if u.SpecDiffs != nil {
|
||||||
diffs, _ = u.SpecDiffs.ListForRun(ctx, latest.ID)
|
diffs, _ = u.SpecDiffs.ListForRun(ctx, viewed.ID)
|
||||||
|
}
|
||||||
|
if u.SubSteps != nil {
|
||||||
|
subSteps, _ = u.SubSteps.ListForRun(ctx, viewed.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t := u.Tiles.Build(ctx, *host, latest)
|
// Sidebar: last 20 runs for this host, newest first. Fail-soft so a
|
||||||
|
// transient DB error doesn't blank the whole page.
|
||||||
|
var history []model.Run
|
||||||
|
if u.Runs != nil {
|
||||||
|
history, _ = u.Runs.ListForHost(ctx, hostID, 20)
|
||||||
|
}
|
||||||
|
t := u.Tiles.Build(ctx, *host, viewed)
|
||||||
replay := ""
|
replay := ""
|
||||||
if latest != nil && u.Logs != nil {
|
replayByStage := map[string]string{}
|
||||||
replay = u.Logs.Replay(latest.ID)
|
if viewed != nil && u.Logs != nil {
|
||||||
|
replay = u.Logs.Replay(viewed.ID)
|
||||||
|
replayByStage = u.Logs.ReplayByStage(viewed.ID)
|
||||||
}
|
}
|
||||||
return templates.HostDetailData{
|
return templates.HostDetailData{
|
||||||
Tile: t,
|
Tile: t,
|
||||||
Stages: stages,
|
Stages: stages,
|
||||||
SpecDiffs: diffs,
|
SpecDiffs: diffs,
|
||||||
|
SubSteps: subSteps,
|
||||||
|
History: history,
|
||||||
|
DefaultStepStage: pickDefaultStep(stages),
|
||||||
LogReplay: replay,
|
LogReplay: replay,
|
||||||
|
LogReplayByStage: replayByStage,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pickDefaultStep chooses which stage the detail page opens expanded by
|
||||||
|
// default. Rule: running → first-failed → Reporting. The operator is
|
||||||
|
// almost always most interested in the thing currently happening (or
|
||||||
|
// the thing that just failed); Reporting is the sensible terminal fallback
|
||||||
|
// because it's where the report link lives.
|
||||||
|
func pickDefaultStep(stages []model.Stage) string {
|
||||||
|
for _, s := range stages {
|
||||||
|
if s.State == model.StageRunning {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range stages {
|
||||||
|
if s.State == model.StageFailed {
|
||||||
|
return s.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Reporting"
|
||||||
|
}
|
||||||
|
|
||||||
// StartRun creates a new Run for the host, issues an agent token, and
|
// StartRun creates a new Run for the host, issues an agent token, and
|
||||||
// transitions Registered→Queued. The dispatcher goroutine picks it up
|
// transitions Registered→Queued. The dispatcher goroutine picks it up
|
||||||
// on its next tick; the happy path is heartbeat-driven (the reporter's
|
// on its next tick; the happy path is heartbeat-driven (the reporter's
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS sub_steps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
||||||
|
stage_name TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
summary_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE (run_id, stage_name, ordinal)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sub_steps_run ON sub_steps(run_id, stage_name, ordinal);
|
||||||
@@ -143,3 +143,69 @@ func waitForSSEEvent(r *bufio.Reader, name string, timeout time.Duration) error
|
|||||||
type timeoutErr struct{}
|
type timeoutErr struct{}
|
||||||
|
|
||||||
func (e *timeoutErr) Error() string { return "timeout waiting for sse event" }
|
func (e *timeoutErr) Error() string { return "timeout waiting for sse event" }
|
||||||
|
|
||||||
|
// TestSSE_SubStepEvent confirms PublishSubStepUpdate lands on the wire
|
||||||
|
// with the exact "substep-{runID}-{stage}-{ordinal}" event name that
|
||||||
|
// detail-page swap targets key on. Without this, the template renders
|
||||||
|
// the right attribute but a middleware or renderer regression silently
|
||||||
|
// drops the payload.
|
||||||
|
func TestSSE_SubStepEvent(t *testing.T) {
|
||||||
|
conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
|
||||||
|
hosts := &store.Hosts{DB: conn}
|
||||||
|
runs := &store.Runs{DB: conn}
|
||||||
|
stages := &store.Stages{DB: conn}
|
||||||
|
diffs := &store.SpecDiffs{DB: conn}
|
||||||
|
arts := &store.Artifacts{DB: conn}
|
||||||
|
hub := events.NewHub()
|
||||||
|
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
||||||
|
tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs}
|
||||||
|
|
||||||
|
orchestrator.SubStepRenderer = func(_ model.SubStep) string {
|
||||||
|
return `<div class="substep">row</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
ui := &api.UI{
|
||||||
|
Hosts: hosts, Runs: runs, Stages: stages, SpecDiffs: diffs, Artifacts: arts,
|
||||||
|
EventHub: hub, Runner: runner, Tiles: tiles,
|
||||||
|
}
|
||||||
|
agent := &api.Agent{
|
||||||
|
Hosts: hosts, Runs: runs, Stages: stages, Artifacts: arts,
|
||||||
|
SpecDiffs: diffs, Runner: runner, EventHub: hub,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := NewRouter(Deps{UI: ui, Agent: agent})
|
||||||
|
srv := httptest.NewServer(router)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /events: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
if err := waitForSSEEvent(reader, "hello", 1*time.Second); err != nil {
|
||||||
|
t.Fatalf("hello preamble: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
runner.PublishSubStepUpdate(context.Background(), model.SubStep{
|
||||||
|
RunID: 42,
|
||||||
|
StageName: "CPUStress",
|
||||||
|
Ordinal: 1,
|
||||||
|
Name: "Memory pass",
|
||||||
|
State: model.StagePassed,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := waitForSSEEvent(reader, "substep-42-CPUStress-1", 2*time.Second); err != nil {
|
||||||
|
t.Fatalf("substep event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+185
-8
@@ -30,6 +30,10 @@ type Writer struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
f *os.File
|
f *os.File
|
||||||
hub *events.Hub
|
hub *events.Hub
|
||||||
|
// counters keyed by Stage (empty key = orphan/framing lines) so each
|
||||||
|
// stage's rendered output numbers from 1. Shared with Replay via
|
||||||
|
// seedCounters on Writer open so restarts don't reset to 1 mid-run.
|
||||||
|
counters map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub owns the per-run Writers. The orchestrator creates one Hub at
|
// Hub owns the per-run Writers. The orchestrator creates one Hub at
|
||||||
@@ -62,11 +66,47 @@ func (h *Hub) WriterFor(runID int64) (*Writer, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||||
}
|
}
|
||||||
w := &Writer{runID: runID, f: f, hub: h.events}
|
w := &Writer{runID: runID, f: f, hub: h.events, counters: seedCounters(path)}
|
||||||
h.writers[runID] = w
|
h.writers[runID] = w
|
||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedCounters scans the on-disk log and returns the per-stage line
|
||||||
|
// counts that were already rendered for this run. Called on Writer open
|
||||||
|
// so a mid-run process restart continues numbering where it left off
|
||||||
|
// — otherwise Append(line) would emit "1" but Replay already shows
|
||||||
|
// "42" on the reload, and anchor permalinks would collide.
|
||||||
|
func seedCounters(path string) map[string]int {
|
||||||
|
counters := map[string]int{}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return counters
|
||||||
|
}
|
||||||
|
for _, raw := range strings.Split(string(b), "\n") {
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tsEnd := strings.IndexByte(raw, ' ')
|
||||||
|
if tsEnd < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := strings.TrimLeft(raw[tsEnd+1:], " ")
|
||||||
|
lvEnd := strings.IndexByte(rest, ' ')
|
||||||
|
if lvEnd < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := rest[lvEnd+1:]
|
||||||
|
stage := ""
|
||||||
|
if strings.HasPrefix(text, "[") {
|
||||||
|
if end := strings.Index(text, "] "); end > 1 {
|
||||||
|
stage = text[1:end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counters[stage]++
|
||||||
|
}
|
||||||
|
return counters
|
||||||
|
}
|
||||||
|
|
||||||
// Close flushes and closes all open run files. Called from main on
|
// Close flushes and closes all open run files. Called from main on
|
||||||
// shutdown so the logs aren't left with buffered data.
|
// shutdown so the logs aren't left with buffered data.
|
||||||
func (h *Hub) Close() {
|
func (h *Hub) Close() {
|
||||||
@@ -92,6 +132,10 @@ func (h *Hub) PathFor(runID int64) string {
|
|||||||
// the pane just stays empty until live events arrive. Does not subscribe
|
// the pane just stays empty until live events arrive. Does not subscribe
|
||||||
// to the SSE hub — callers are expected to pair this with a live
|
// to the SSE hub — callers are expected to pair this with a live
|
||||||
// sse-swap target on the same element.
|
// sse-swap target on the same element.
|
||||||
|
//
|
||||||
|
// Line numbers are rebuilt from scratch here so a page reload shows
|
||||||
|
// stable IDs that match what Append will emit for future lines (the
|
||||||
|
// Writer's counters are seeded from disk on open).
|
||||||
func (h *Hub) Replay(runID int64) string {
|
func (h *Hub) Replay(runID int64) string {
|
||||||
path := h.PathFor(runID)
|
path := h.PathFor(runID)
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
@@ -99,6 +143,7 @@ func (h *Hub) Replay(runID int64) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
var out strings.Builder
|
var out strings.Builder
|
||||||
|
counters := map[string]int{}
|
||||||
for _, raw := range strings.Split(string(b), "\n") {
|
for _, raw := range strings.Split(string(b), "\n") {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
continue
|
continue
|
||||||
@@ -129,7 +174,111 @@ func (h *Hub) Replay(runID int64) string {
|
|||||||
text = text[end+2:]
|
text = text[end+2:]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.WriteString(renderLogSSE(Line{TS: ts, Level: level, Stage: stage, Text: text}))
|
counters[stage]++
|
||||||
|
out.WriteString(renderLogSSE(runID, counters[stage], Line{TS: ts, Level: level, Stage: stage, Text: text}))
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplayByStage scans the on-disk log once and returns a per-stage map
|
||||||
|
// of pre-rendered HTML strings, keyed by stage name (orphan/framing
|
||||||
|
// lines are keyed under ""). This is the one-pass alternative to
|
||||||
|
// calling ReplayForStage per stage: the detail-page renders nine stage
|
||||||
|
// panels, and doing nine file scans per page load is wasteful. Line
|
||||||
|
// numbers are per-stage so they agree with the counters Append uses
|
||||||
|
// for the same run.
|
||||||
|
func (h *Hub) ReplayByStage(runID int64) map[string]string {
|
||||||
|
path := h.PathFor(runID)
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
bufs := map[string]*strings.Builder{}
|
||||||
|
counters := map[string]int{}
|
||||||
|
for _, raw := range strings.Split(string(b), "\n") {
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tsEnd := strings.IndexByte(raw, ' ')
|
||||||
|
if tsEnd < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, raw[:tsEnd])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := strings.TrimLeft(raw[tsEnd+1:], " ")
|
||||||
|
lvEnd := strings.IndexByte(rest, ' ')
|
||||||
|
if lvEnd < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
level := strings.ToLower(rest[:lvEnd])
|
||||||
|
text := rest[lvEnd+1:]
|
||||||
|
stage := ""
|
||||||
|
if strings.HasPrefix(text, "[") {
|
||||||
|
if end := strings.Index(text, "] "); end > 1 {
|
||||||
|
stage = text[1:end]
|
||||||
|
text = text[end+2:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counters[stage]++
|
||||||
|
sb, ok := bufs[stage]
|
||||||
|
if !ok {
|
||||||
|
sb = &strings.Builder{}
|
||||||
|
bufs[stage] = sb
|
||||||
|
}
|
||||||
|
sb.WriteString(renderLogSSE(runID, counters[stage], Line{TS: ts, Level: level, Stage: stage, Text: text}))
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(bufs))
|
||||||
|
for k, sb := range bufs {
|
||||||
|
out[k] = sb.String()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplayForStage returns only the log lines whose Stage matches stageName
|
||||||
|
// (pass "" for orphan/framing lines). Used by the detail-page ActiveStep
|
||||||
|
// renderer so each expanded step shows only its own log history. Line
|
||||||
|
// numbers here are per-stage so they agree with what Append emits live.
|
||||||
|
func (h *Hub) ReplayForStage(runID int64, stageName string) string {
|
||||||
|
path := h.PathFor(runID)
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
ord := 0
|
||||||
|
for _, raw := range strings.Split(string(b), "\n") {
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tsEnd := strings.IndexByte(raw, ' ')
|
||||||
|
if tsEnd < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, raw[:tsEnd])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := strings.TrimLeft(raw[tsEnd+1:], " ")
|
||||||
|
lvEnd := strings.IndexByte(rest, ' ')
|
||||||
|
if lvEnd < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
level := strings.ToLower(rest[:lvEnd])
|
||||||
|
text := rest[lvEnd+1:]
|
||||||
|
stage := ""
|
||||||
|
if strings.HasPrefix(text, "[") {
|
||||||
|
if end := strings.Index(text, "] "); end > 1 {
|
||||||
|
stage = text[1:end]
|
||||||
|
text = text[end+2:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stage != stageName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ord++
|
||||||
|
out.WriteString(renderLogSSE(runID, ord, Line{TS: ts, Level: level, Stage: stage, Text: text}))
|
||||||
}
|
}
|
||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
@@ -154,8 +303,13 @@ func (w *Writer) Append(line Line) {
|
|||||||
if _, err := w.f.WriteString(stamped); err != nil {
|
if _, err := w.f.WriteString(stamped); err != nil {
|
||||||
log.Printf("logs: write run-%d: %v", w.runID, err)
|
log.Printf("logs: write run-%d: %v", w.runID, err)
|
||||||
}
|
}
|
||||||
|
if w.counters == nil {
|
||||||
|
w.counters = map[string]int{}
|
||||||
|
}
|
||||||
|
w.counters[line.Stage]++
|
||||||
|
ord := w.counters[line.Stage]
|
||||||
if w.hub != nil {
|
if w.hub != nil {
|
||||||
payload := renderLogSSE(line)
|
payload := renderLogSSE(w.runID, ord, line)
|
||||||
w.hub.Publish(events.Event{
|
w.hub.Publish(events.Event{
|
||||||
Name: fmt.Sprintf("log-%d", w.runID),
|
Name: fmt.Sprintf("log-%d", w.runID),
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
@@ -182,18 +336,41 @@ func (w *Writer) Close() error {
|
|||||||
|
|
||||||
// renderLogSSE returns an HTMX-compatible fragment. The detail-page
|
// renderLogSSE returns an HTMX-compatible fragment. The detail-page
|
||||||
// panes contain <div id="log-N-..." hx-swap="beforeend">: each event
|
// panes contain <div id="log-N-..." hx-swap="beforeend">: each event
|
||||||
// appends one <div class="log-line log-LEVEL"> to them. Stage, if set,
|
// appends one <div class="log-line log-LEVEL"> to them. ord is the
|
||||||
// is rendered as a dim prefix so the "All" pane stays disambiguable
|
// per-(run, stage) 1-based line number; combined with runID + stage it
|
||||||
// even with multiple stages interleaved.
|
// forms a stable permalink id of the form L{run}-{stage}-{ord} (stage
|
||||||
func renderLogSSE(l Line) string {
|
// defaults to "all" when the line has no stage, so orphan/framing lines
|
||||||
|
// still anchor uniquely).
|
||||||
|
//
|
||||||
|
// Shape:
|
||||||
|
//
|
||||||
|
// <div class="log-line log-{level}" id="L{run}-{stage}-{ord}" data-ts="RFC3339Nano">
|
||||||
|
// <a class="log-anchor" href="#L{run}-{stage}-{ord}">#</a>
|
||||||
|
// <span class="ln">{ord}</span>
|
||||||
|
// <span class="lvl">{LEVEL}</span>
|
||||||
|
// <span class="log-ts">15:04:05</span>
|
||||||
|
// (optional) <span class="log-stage">[{stage}]</span>
|
||||||
|
// <span class="log-text">{text}</span>
|
||||||
|
// </div>
|
||||||
|
func renderLogSSE(runID int64, ord int, l Line) string {
|
||||||
level := strings.ToLower(l.Level)
|
level := strings.ToLower(l.Level)
|
||||||
|
stageKey := l.Stage
|
||||||
|
if stageKey == "" {
|
||||||
|
stageKey = "all"
|
||||||
|
}
|
||||||
|
anchorID := fmt.Sprintf("L%d-%s-%d", runID, html.EscapeString(stageKey), ord)
|
||||||
stagePrefix := ""
|
stagePrefix := ""
|
||||||
if l.Stage != "" {
|
if l.Stage != "" {
|
||||||
stagePrefix = fmt.Sprintf(`<span class="log-stage">[%s]</span> `, html.EscapeString(l.Stage))
|
stagePrefix = fmt.Sprintf(`<span class="log-stage">[%s]</span> `, html.EscapeString(l.Stage))
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
`<div class="log-line log-%s">%s %s%s</div>`,
|
`<div class="log-line log-%s" id="%s" data-ts="%s"><a class="log-anchor" href="#%s">#</a><span class="ln">%d</span><span class="lvl">%s</span><span class="log-ts">%s</span>%s<span class="log-text">%s</span></div>`,
|
||||||
html.EscapeString(level),
|
html.EscapeString(level),
|
||||||
|
anchorID,
|
||||||
|
html.EscapeString(l.TS.Format(time.RFC3339Nano)),
|
||||||
|
anchorID,
|
||||||
|
ord,
|
||||||
|
html.EscapeString(strings.ToUpper(level)),
|
||||||
html.EscapeString(l.TS.Format("15:04:05")),
|
html.EscapeString(l.TS.Format("15:04:05")),
|
||||||
stagePrefix,
|
stagePrefix,
|
||||||
html.EscapeString(l.Text),
|
html.EscapeString(l.Text),
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ type Stage struct {
|
|||||||
SummaryJSON string
|
SummaryJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubStep is a finer-grained unit within a Stage, authored by the agent.
|
||||||
|
// Not every stage has sub-steps; those that do (CPUStress, SMART per-disk,
|
||||||
|
// Storage per-disk, GPU per-device) surface them so the UI can render a
|
||||||
|
// GitHub-Actions-style collapsible list. Sub-steps share the StageState
|
||||||
|
// enum with Stage; Ordinal is 0-based within StageName for a given RunID
|
||||||
|
// and is how the UI and SSE events key each row.
|
||||||
|
type SubStep struct {
|
||||||
|
ID int64
|
||||||
|
RunID int64
|
||||||
|
StageName string
|
||||||
|
Ordinal int
|
||||||
|
Name string
|
||||||
|
State StageState
|
||||||
|
StartedAt *time.Time
|
||||||
|
CompletedAt *time.Time
|
||||||
|
SummaryJSON string
|
||||||
|
}
|
||||||
|
|
||||||
type Measurement struct {
|
type Measurement struct {
|
||||||
ID int64
|
ID int64
|
||||||
RunID int64
|
RunID int64
|
||||||
|
|||||||
@@ -154,6 +154,25 @@ var TileRenderer func(ctx context.Context, host model.Host, latest *model.Run) s
|
|||||||
// orchestrator stays free of template imports.
|
// orchestrator stays free of template imports.
|
||||||
var PipelineRenderer func(run *model.Run, stages []model.Stage) string
|
var PipelineRenderer func(run *model.Run, stages []model.Stage) string
|
||||||
|
|
||||||
|
// SubStepRenderer renders a single sub-step row fragment. Fires on
|
||||||
|
// every sub-step state transition (running → passed/failed) so the
|
||||||
|
// detail page's `<div sse-swap="substep-{runID}-{stage}-{ordinal}">`
|
||||||
|
// target updates without reloading.
|
||||||
|
var SubStepRenderer func(ss model.SubStep) string
|
||||||
|
|
||||||
|
// PublishSubStepUpdate broadcasts a single sub-step row. Callers give
|
||||||
|
// the just-persisted SubStep; we render + fan out. Safe to call when
|
||||||
|
// no renderer is wired; drops silently.
|
||||||
|
func (r *Runner) PublishSubStepUpdate(ctx context.Context, ss model.SubStep) {
|
||||||
|
if SubStepRenderer == nil || r.EventHub == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.EventHub.Publish(events.Event{
|
||||||
|
Name: fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal),
|
||||||
|
Payload: SubStepRenderer(ss),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// HostDetailFragments is the pre-rendered bundle of HTML fragments a
|
// HostDetailFragments is the pre-rendered bundle of HTML fragments a
|
||||||
// single PublishHostDetail call broadcasts over SSE. Summary and Actions
|
// single PublishHostDetail call broadcasts over SSE. Summary and Actions
|
||||||
// are always set; SpecDiffs and Hold are empty strings when there is no
|
// are always set; SpecDiffs and Hold are empty strings when there is no
|
||||||
|
|||||||
@@ -154,6 +154,45 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
|
|||||||
return &run, nil
|
return &run, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListForHost returns the most recent `limit` runs for a host, newest
|
||||||
|
// first. Caller uses this to drive the host-detail runs sidebar (last 20
|
||||||
|
// by default, Phase 2). Zero/negative limit falls back to a safe cap so
|
||||||
|
// a mistaken call can't scan the whole history into memory.
|
||||||
|
func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]model.Run, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
rows, err := r.DB.QueryContext(ctx, `
|
||||||
|
SELECT id, host_id, state, COALESCE(result,''), COALESCE(failed_stage,''),
|
||||||
|
COALESCE(next_boot_target,''), agent_token_hash, started_at,
|
||||||
|
completed_at, COALESCE(report_path,''), COALESCE(hold_ip,''),
|
||||||
|
COALESCE(override_flags_json,''), COALESCE(non_destructive,0)
|
||||||
|
FROM runs
|
||||||
|
WHERE host_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, hostID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []model.Run
|
||||||
|
for rows.Next() {
|
||||||
|
var run model.Run
|
||||||
|
var completedAt sql.NullTime
|
||||||
|
if err := rows.Scan(&run.ID, &run.HostID, &run.State, &run.Result, &run.FailedStage,
|
||||||
|
&run.NextBootTarget, &run.AgentTokenHash, &run.StartedAt,
|
||||||
|
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if completedAt.Valid {
|
||||||
|
run.CompletedAt = &completedAt.Time
|
||||||
|
}
|
||||||
|
out = append(out, run)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Active returns all runs in non-terminal states.
|
// Active returns all runs in non-terminal states.
|
||||||
func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
|
func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
|
||||||
rows, err := r.DB.QueryContext(ctx, `
|
rows, err := r.DB.QueryContext(ctx, `
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubSteps is the persistence layer for the sub_steps table. Sub-steps
|
||||||
|
// are authored by the agent (one per disk in SMART, one per disk-operation
|
||||||
|
// in Storage, CPU+Memory passes in CPUStress, etc.) and surfaced in the
|
||||||
|
// detail page as a collapsible list under each stage.
|
||||||
|
type SubSteps struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert inserts-or-replaces a sub-step row keyed by (run_id, stage_name,
|
||||||
|
// ordinal). Replace semantics let the agent push a row twice — once when
|
||||||
|
// the sub-step starts (state=running, completed_at=nil) and again when
|
||||||
|
// it finishes (state=passed|failed, completed_at set) — without the
|
||||||
|
// caller needing to track whether the row already exists.
|
||||||
|
func (s *SubSteps) Upsert(ctx context.Context, ss model.SubStep) error {
|
||||||
|
// Preserve the original started_at if the caller didn't supply one
|
||||||
|
// (a "complete" call that only knows the end time still needs to
|
||||||
|
// leave the start time alone). ON CONFLICT DO UPDATE with COALESCE
|
||||||
|
// handles the merge in one round-trip.
|
||||||
|
_, err := s.DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO sub_steps(run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(run_id, stage_name, ordinal) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
state = excluded.state,
|
||||||
|
started_at = COALESCE(excluded.started_at, sub_steps.started_at),
|
||||||
|
completed_at = COALESCE(excluded.completed_at, sub_steps.completed_at),
|
||||||
|
summary_json = CASE WHEN excluded.summary_json = '{}' THEN sub_steps.summary_json ELSE excluded.summary_json END
|
||||||
|
`,
|
||||||
|
ss.RunID, ss.StageName, ss.Ordinal, ss.Name, string(ss.State),
|
||||||
|
nullTime(ss.StartedAt), nullTime(ss.CompletedAt), jsonOrDefault(ss.SummaryJSON))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert sub-step: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a single sub-step by its natural key. Returns ErrNotFound
|
||||||
|
// if none exists.
|
||||||
|
func (s *SubSteps) Get(ctx context.Context, runID int64, stageName string, ordinal int) (*model.SubStep, error) {
|
||||||
|
row := s.DB.QueryRowContext(ctx, `
|
||||||
|
SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
|
||||||
|
FROM sub_steps WHERE run_id = ? AND stage_name = ? AND ordinal = ?
|
||||||
|
`, runID, stageName, ordinal)
|
||||||
|
ss, err := scanSubStep(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return ss, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForRun returns every sub-step for a run, ordered by (stage_name,
|
||||||
|
// ordinal). The caller groups by StageName to build the UI.
|
||||||
|
func (s *SubSteps) ListForRun(ctx context.Context, runID int64) ([]model.SubStep, error) {
|
||||||
|
rows, err := s.DB.QueryContext(ctx, `
|
||||||
|
SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
|
||||||
|
FROM sub_steps WHERE run_id = ? ORDER BY stage_name, ordinal
|
||||||
|
`, runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []model.SubStep
|
||||||
|
for rows.Next() {
|
||||||
|
ss, err := scanSubStep(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *ss)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForStage returns every sub-step for one stage of a run, in ordinal
|
||||||
|
// order. Convenience over filtering ListForRun client-side.
|
||||||
|
func (s *SubSteps) ListForStage(ctx context.Context, runID int64, stageName string) ([]model.SubStep, error) {
|
||||||
|
rows, err := s.DB.QueryContext(ctx, `
|
||||||
|
SELECT id, run_id, stage_name, ordinal, name, state, started_at, completed_at, summary_json
|
||||||
|
FROM sub_steps WHERE run_id = ? AND stage_name = ? ORDER BY ordinal
|
||||||
|
`, runID, stageName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []model.SubStep
|
||||||
|
for rows.Next() {
|
||||||
|
ss, err := scanSubStep(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *ss)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSubStep(r scanner) (*model.SubStep, error) {
|
||||||
|
var ss model.SubStep
|
||||||
|
var started, completed sql.NullTime
|
||||||
|
if err := r.Scan(&ss.ID, &ss.RunID, &ss.StageName, &ss.Ordinal, &ss.Name,
|
||||||
|
&ss.State, &started, &completed, &ss.SummaryJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if started.Valid {
|
||||||
|
t := started.Time
|
||||||
|
ss.StartedAt = &t
|
||||||
|
}
|
||||||
|
if completed.Valid {
|
||||||
|
t := completed.Time
|
||||||
|
ss.CompletedAt = &t
|
||||||
|
}
|
||||||
|
return &ss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullTime(t *time.Time) any {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonOrDefault(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSubStepsUpsertAndList covers the two-phase life cycle the agent
|
||||||
|
// is meant to exercise: an initial "started" upsert with state=running
|
||||||
|
// and started_at set, then a terminal upsert that flips state to passed
|
||||||
|
// and fills completed_at without clobbering started_at. Ordering falls
|
||||||
|
// out of the (stage_name, ordinal) index — ListForRun must return rows
|
||||||
|
// in that deterministic order even when they were inserted out of order.
|
||||||
|
func TestSubStepsUpsertAndList(t *testing.T) {
|
||||||
|
runs := newDB(t)
|
||||||
|
_, runID := seedRun(t, runs)
|
||||||
|
ss := &store.SubSteps{DB: runs.DB}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
start := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC)
|
||||||
|
end := start.Add(3 * time.Minute)
|
||||||
|
|
||||||
|
// Insert CPUStress ordinals out of order to prove ListForRun orders
|
||||||
|
// by (stage, ordinal) rather than insertion time.
|
||||||
|
if err := ss.Upsert(ctx, model.SubStep{
|
||||||
|
RunID: runID, StageName: "CPUStress", Ordinal: 1,
|
||||||
|
Name: "Memory pass", State: model.StageRunning, StartedAt: &start,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert CPUStress/1 running: %v", err)
|
||||||
|
}
|
||||||
|
if err := ss.Upsert(ctx, model.SubStep{
|
||||||
|
RunID: runID, StageName: "CPUStress", Ordinal: 0,
|
||||||
|
Name: "CPU pass", State: model.StagePassed, StartedAt: &start, CompletedAt: &end,
|
||||||
|
SummaryJSON: `{"elapsed_secs":180}`,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert CPUStress/0 passed: %v", err)
|
||||||
|
}
|
||||||
|
// Terminal update for ordinal 1 — only completed_at + state; started_at
|
||||||
|
// intentionally omitted so COALESCE preserves the original value.
|
||||||
|
if err := ss.Upsert(ctx, model.SubStep{
|
||||||
|
RunID: runID, StageName: "CPUStress", Ordinal: 1,
|
||||||
|
Name: "Memory pass", State: model.StagePassed, CompletedAt: &end,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("upsert CPUStress/1 passed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := ss.ListForRun(ctx, runID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListForRun: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("got %d rows, want 2", len(list))
|
||||||
|
}
|
||||||
|
if list[0].Name != "CPU pass" || list[1].Name != "Memory pass" {
|
||||||
|
t.Fatalf("order: %+v", list)
|
||||||
|
}
|
||||||
|
if list[1].State != model.StagePassed {
|
||||||
|
t.Fatalf("memory pass state = %q, want passed", list[1].State)
|
||||||
|
}
|
||||||
|
if list[1].StartedAt == nil {
|
||||||
|
t.Fatalf("memory pass started_at was wiped by terminal upsert")
|
||||||
|
}
|
||||||
|
if !list[1].StartedAt.Equal(start) {
|
||||||
|
t.Fatalf("started_at = %v, want %v", list[1].StartedAt, start)
|
||||||
|
}
|
||||||
|
if list[0].SummaryJSON != `{"elapsed_secs":180}` {
|
||||||
|
t.Fatalf("CPU pass summary = %q, want {\"elapsed_secs\":180}", list[0].SummaryJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForStage scopes to a single stage.
|
||||||
|
only, err := ss.ListForStage(ctx, runID, "CPUStress")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListForStage: %v", err)
|
||||||
|
}
|
||||||
|
if len(only) != 2 {
|
||||||
|
t.Fatalf("ListForStage CPUStress: got %d, want 2", len(only))
|
||||||
|
}
|
||||||
|
other, err := ss.ListForStage(ctx, runID, "Storage")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListForStage Storage: %v", err)
|
||||||
|
}
|
||||||
|
if len(other) != 0 {
|
||||||
|
t.Fatalf("ListForStage Storage should be empty, got %d", len(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubStepsSummaryPreserveOnDefault verifies the intentional special-
|
||||||
|
// case: when the second upsert supplies the default empty-object summary
|
||||||
|
// ('{}'), the prior non-default summary is kept. This lets a "complete"
|
||||||
|
// call that only carries timing update the row without wiping the rich
|
||||||
|
// summary the "start" call persisted.
|
||||||
|
func TestSubStepsSummaryPreserveOnDefault(t *testing.T) {
|
||||||
|
runs := newDB(t)
|
||||||
|
_, runID := seedRun(t, runs)
|
||||||
|
ss := &store.SubSteps{DB: runs.DB}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := ss.Upsert(ctx, model.SubStep{
|
||||||
|
RunID: runID, StageName: "SMART", Ordinal: 0,
|
||||||
|
Name: "smartctl /dev/sda", State: model.StageRunning,
|
||||||
|
SummaryJSON: `{"device":"/dev/sda"}`,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("first upsert: %v", err)
|
||||||
|
}
|
||||||
|
// Terminal with empty summary — should not blow away the device field.
|
||||||
|
if err := ss.Upsert(ctx, model.SubStep{
|
||||||
|
RunID: runID, StageName: "SMART", Ordinal: 0,
|
||||||
|
Name: "smartctl /dev/sda", State: model.StagePassed,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("terminal upsert: %v", err)
|
||||||
|
}
|
||||||
|
got, err := ss.Get(ctx, runID, "SMART", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if got.SummaryJSON != `{"device":"/dev/sda"}` {
|
||||||
|
t.Fatalf("summary wiped: %q", got.SummaryJSON)
|
||||||
|
}
|
||||||
|
if got.State != model.StagePassed {
|
||||||
|
t.Fatalf("state = %q, want passed", got.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
+408
-15
@@ -202,8 +202,14 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
|||||||
.tile-pass { border-color: rgba(53,194,123,.5); }
|
.tile-pass { border-color: rgba(53,194,123,.5); }
|
||||||
.tile-active { border-color: var(--accent); }
|
.tile-active { border-color: var(--accent); }
|
||||||
|
|
||||||
.form-wrap { max-width: 640px; }
|
.form-wrap { max-width: 640px; display: flex; flex-direction: column; gap: 16px; }
|
||||||
.form-wrap h1 { font-size: 20px; }
|
.form-wrap h1 { font-size: 20px; margin: 0; }
|
||||||
|
.form-wrap-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.host-form { display: flex; flex-direction: column; gap: 14px; }
|
.host-form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
.host-form label { display: flex; flex-direction: column; gap: 4px; color: var(--text-dim); font-size: 13px; }
|
.host-form label { display: flex; flex-direction: column; gap: 4px; color: var(--text-dim); font-size: 13px; }
|
||||||
@@ -245,14 +251,10 @@ button.danger:hover { background: rgba(229,100,102,.1); }
|
|||||||
|
|
||||||
body.bare main { max-width: none; }
|
body.bare main { max-width: none; }
|
||||||
|
|
||||||
.quick-register {
|
/* .quick-register now inherits card shell from .detail-section; these
|
||||||
background: var(--bg-elev);
|
rules only cover its own content. */
|
||||||
border: 1px solid var(--border);
|
.quick-register h2 { margin: 0 0 8px; font-size: 15px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); font-weight: 600; }
|
||||||
border-radius: var(--radius);
|
.quick-register h2 .muted { text-transform: none; letter-spacing: 0; }
|
||||||
padding: 16px 18px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.quick-register h2 { margin: 0 0 8px; font-size: 16px; }
|
|
||||||
.quick-register p { margin: 6px 0; font-size: 13px; color: var(--text-dim); }
|
.quick-register p { margin: 6px 0; font-size: 13px; color: var(--text-dim); }
|
||||||
.quick-register p b { color: var(--text); }
|
.quick-register p b { color: var(--text); }
|
||||||
.quick-register .muted { color: var(--text-dim); font-weight: 400; }
|
.quick-register .muted { color: var(--text-dim); font-weight: 400; }
|
||||||
@@ -270,14 +272,31 @@ body.bare main { max-width: none; }
|
|||||||
}
|
}
|
||||||
.quick-register .one-liner code { white-space: pre; }
|
.quick-register .one-liner code { white-space: pre; }
|
||||||
|
|
||||||
.manual-register { margin-top: 16px; }
|
.manual-register-card { padding-top: 10px; padding-bottom: 14px; }
|
||||||
.manual-register summary {
|
.manual-register summary {
|
||||||
|
list-style: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-dim);
|
display: flex;
|
||||||
font-size: 13px;
|
align-items: center;
|
||||||
padding: 6px 0;
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
.manual-register summary:hover { color: var(--text); }
|
.manual-register summary::before {
|
||||||
|
content: "▸";
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
transition: transform .1s ease;
|
||||||
|
}
|
||||||
|
.manual-register[open] > summary::before { transform: rotate(90deg); }
|
||||||
|
.manual-register summary h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.manual-register summary:hover h2 { color: var(--text); }
|
||||||
.manual-register[open] summary { margin-bottom: 12px; }
|
.manual-register[open] summary { margin-bottom: 12px; }
|
||||||
|
|
||||||
/* ===== Host detail page ===== */
|
/* ===== Host detail page ===== */
|
||||||
@@ -549,3 +568,377 @@ body.bare main { max-width: none; }
|
|||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); }
|
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); }
|
||||||
50% { box-shadow: 0 0 0 8px rgba(60,130,246,0); }
|
50% { box-shadow: 0 0 0 8px rgba(60,130,246,0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Host detail v2 — GitHub-Actions-style layout ===== */
|
||||||
|
|
||||||
|
.detail-v2 { gap: 12px; }
|
||||||
|
|
||||||
|
.host-meta-drawer {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
.host-meta-drawer > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.host-meta-drawer > summary::before {
|
||||||
|
content: "▸";
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
transition: transform .1s ease;
|
||||||
|
}
|
||||||
|
.host-meta-drawer[open] > summary::before { transform: rotate(90deg); }
|
||||||
|
.host-meta-drawer .meta-summary-label { color: var(--text); font-weight: 600; }
|
||||||
|
.host-meta-drawer .meta-summary-mac { font-family: var(--mono); margin-left: auto; }
|
||||||
|
.host-meta-drawer[open] > summary { margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
|
||||||
|
|
||||||
|
.run-header {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.run-header.tile-fail { border-color: rgba(229,100,102,.6); }
|
||||||
|
.run-header.tile-pass { border-color: rgba(53,194,123,.5); }
|
||||||
|
.run-header.tile-active { border-color: var(--accent); }
|
||||||
|
.run-header-left { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
|
||||||
|
.run-header-right { display: flex; align-items: center; gap: 14px; font-size: 13px; }
|
||||||
|
.run-header .detail-name { margin: 0; font-size: 22px; }
|
||||||
|
.run-number { font-family: var(--mono); font-size: 15px; color: var(--text-dim); }
|
||||||
|
.run-status-badge {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.run-status-pass { background: rgba(53,194,123,.15); border-color: rgba(53,194,123,.5); color: var(--success); }
|
||||||
|
.run-status-fail { background: rgba(229,100,102,.12); border-color: rgba(229,100,102,.5); color: var(--danger); }
|
||||||
|
.run-status-active { background: rgba(60,130,246,.15); border-color: rgba(60,130,246,.5); color: var(--accent); }
|
||||||
|
.run-duration { font-family: var(--mono); font-size: 13px; color: var(--text-dim); }
|
||||||
|
.run-failed-stage { color: var(--danger); }
|
||||||
|
.run-failed-stage strong { font-family: var(--mono); }
|
||||||
|
.run-diffs { color: var(--danger); }
|
||||||
|
|
||||||
|
.hold-banner {
|
||||||
|
background: rgba(229,100,102,.1);
|
||||||
|
border: 1px solid rgba(229,100,102,.5);
|
||||||
|
border-left: 4px solid var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.hold-banner-label { color: var(--danger); font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.hold-banner .hold-ssh {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: all;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(0,0,0,.3);
|
||||||
|
border: 1px solid rgba(229,100,102,.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.detail-hold-placeholder { display: none; }
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 260px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.detail-body { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.active-step-pane { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.detail-empty {
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.step[open] { border-color: var(--accent); }
|
||||||
|
.step-passed { border-left: 3px solid var(--success); }
|
||||||
|
.step-running { border-left: 3px solid var(--accent); }
|
||||||
|
.step-failed { border-left: 3px solid var(--danger); }
|
||||||
|
.step-skipped { opacity: .55; }
|
||||||
|
.step-pending { opacity: .7; }
|
||||||
|
.step > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.step > summary::-webkit-details-marker { display: none; }
|
||||||
|
.step-summary .stage-dot { width: 20px; height: 20px; font-size: 12px; flex-shrink: 0; }
|
||||||
|
.step-name { font-weight: 600; color: var(--text); flex: 1; }
|
||||||
|
.step-duration { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||||
|
.step-body {
|
||||||
|
padding: 8px 14px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.substep-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.substep {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.substep-badge {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.substep-badge-passed { background: var(--success); border-color: var(--success); color: #0b0d12; }
|
||||||
|
.substep-badge-running { background: var(--accent-strong); border-color: var(--accent); color: #fff; animation: pulse 1.2s ease-in-out infinite; }
|
||||||
|
.substep-badge-failed { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
|
.substep-badge-skipped { opacity: .5; }
|
||||||
|
.substep-name { flex: 1; }
|
||||||
|
.substep-duration { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||||
|
.substep-failed { border-color: rgba(229,100,102,.5); }
|
||||||
|
.substep-running { border-color: rgba(60,130,246,.5); }
|
||||||
|
|
||||||
|
.log-search-wrap { display: flex; }
|
||||||
|
.log-search {
|
||||||
|
flex: 1;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-search::placeholder { color: var(--text-dim); }
|
||||||
|
.log-search:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Log pane: now a standalone block (no tabs). Each step owns its pane. */
|
||||||
|
.step .log-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 6px 0;
|
||||||
|
background: #0b0d12;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
order: unset;
|
||||||
|
}
|
||||||
|
.step .log-pane:empty::before {
|
||||||
|
content: "(no log output for this step yet)";
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
.log-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px 48px 56px 72px auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.log-line:target { background: rgba(60,130,246,.12); border-left-color: var(--accent); }
|
||||||
|
.log-line .log-anchor {
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.log-line:hover .log-anchor { opacity: 1; }
|
||||||
|
.log-line .ln { color: var(--text-dim); opacity: .6; text-align: right; user-select: none; }
|
||||||
|
.log-line .lvl { color: var(--text-dim); text-transform: uppercase; font-size: 10px; font-weight: 700; letter-spacing: .5px; }
|
||||||
|
.log-line .log-ts { color: var(--text-dim); opacity: .75; }
|
||||||
|
.log-line .log-stage { color: var(--text-dim); opacity: .75; margin-right: 4px; }
|
||||||
|
.log-line .log-text { color: var(--text); white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.log-line.log-warn .log-text { color: var(--warn); }
|
||||||
|
.log-line.log-warn .lvl { color: var(--warn); }
|
||||||
|
.log-line.log-error .log-text { color: var(--danger); }
|
||||||
|
.log-line.log-error .lvl { color: var(--danger); }
|
||||||
|
.log-line.log-debug { opacity: .6; }
|
||||||
|
.log-line.log-hit { background: rgba(228,169,75,.08); }
|
||||||
|
|
||||||
|
.runs-sidebar {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px;
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.runs-sidebar-heading {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.runs-sidebar-empty { color: var(--text-dim); font-size: 13px; margin: 0; }
|
||||||
|
.runs-sidebar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.runs-sidebar-item a {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.runs-sidebar-item a:hover { background: var(--bg-elev-2); text-decoration: none; }
|
||||||
|
.runs-sidebar-active a { background: rgba(60,130,246,.12); border: 1px solid rgba(60,130,246,.5); }
|
||||||
|
.runs-sidebar-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.runs-sidebar-dot-pass { background: var(--success); border-color: var(--success); color: #0b0d12; }
|
||||||
|
.runs-sidebar-dot-fail { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
|
.runs-sidebar-dot-active { background: var(--accent-strong); border-color: var(--accent); color: #fff; }
|
||||||
|
.runs-sidebar-id { font-family: var(--mono); font-weight: 600; }
|
||||||
|
.runs-sidebar-started { color: var(--text-dim); }
|
||||||
|
.runs-sidebar-duration { font-family: var(--mono); color: var(--text-dim); font-size: 11px; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.btn-danger {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(229,100,102,.1); }
|
||||||
|
|
||||||
|
/* ---------- Dashboard tile mini run-view (Phase 3) ---------------- */
|
||||||
|
|
||||||
|
/* Small variant of stage-dot for the compact step list. Same colour
|
||||||
|
rules as the full-size pipeline dot so operators read one language
|
||||||
|
everywhere; only the geometry shrinks. */
|
||||||
|
.stage-dot-sm {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
font-size: 9px;
|
||||||
|
border-width: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-meta-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 4px 0 6px;
|
||||||
|
}
|
||||||
|
.tile-run-id { font-variant-numeric: tabular-nums; }
|
||||||
|
.tile-run-duration { margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.tile-steplist {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2px 10px;
|
||||||
|
}
|
||||||
|
.tile-steplist .tile-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-dim);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tile-steplist .tile-step-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Passed/failed/running steps keep full-strength text so the eye jumps
|
||||||
|
to active work; pending/skipped fade back into the background. */
|
||||||
|
.tile-step-passed .tile-step-name,
|
||||||
|
.tile-step-failed .tile-step-name,
|
||||||
|
.tile-step-running .tile-step-name { color: var(--text); }
|
||||||
|
.tile-step-skipped { opacity: .5; }
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Detail-page client behaviors. Loaded in layout.templ with `defer` so the
|
||||||
|
// DOM is parsed before any listeners fire. Three jobs:
|
||||||
|
//
|
||||||
|
// 1. Auto-advance: when a substep-* SSE event lands with state=running,
|
||||||
|
// open the parent step panel and collapse any previously-running step
|
||||||
|
// that's now completed. Keeps the operator's attention on the thing
|
||||||
|
// that's currently moving without manual clicks.
|
||||||
|
// 2. In-step search: filter `.log-line` rows inside the current step by
|
||||||
|
// substring match. Client-side only — the log pane's `<details>` ancestor
|
||||||
|
// scopes the filter naturally.
|
||||||
|
// 3. Permalink scroll + highlight: when the URL carries `#L{run}-{stage}-{ord}`
|
||||||
|
// on load, scroll that log line into view; anchor clicks update
|
||||||
|
// `location.hash` without a reload.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// --- 1. auto-advance on substep SSE ---------------------------------
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:sseMessage', function (ev) {
|
||||||
|
var name = ev.detail && ev.detail.type;
|
||||||
|
if (!name || name.indexOf('substep-') !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// After htmx has applied the swap, check which step the just-updated
|
||||||
|
// substep belongs to. We scan *after* the swap so we see the new
|
||||||
|
// class ("substep-running" / "substep-passed") rather than the old.
|
||||||
|
setTimeout(function () {
|
||||||
|
autoAdvance();
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function autoAdvance() {
|
||||||
|
var steps = document.querySelectorAll('.step[data-stage]');
|
||||||
|
var runningStep = null;
|
||||||
|
steps.forEach(function (step) {
|
||||||
|
if (step.querySelector('.substep-running')) {
|
||||||
|
runningStep = step;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!runningStep) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Open the running step; collapse any other open step that no longer
|
||||||
|
// has a running substep. The default-open step picked server-side
|
||||||
|
// stays open if nothing is running yet.
|
||||||
|
steps.forEach(function (step) {
|
||||||
|
if (step === runningStep) {
|
||||||
|
if (!step.open) { step.open = true; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step.open && !step.querySelector('.substep-running')) {
|
||||||
|
// Leave the "currently-failed" step open even when we
|
||||||
|
// auto-advance — operator still wants to see what broke.
|
||||||
|
if (step.classList.contains('step-failed')) { return; }
|
||||||
|
step.open = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. in-step search ----------------------------------------------
|
||||||
|
|
||||||
|
document.body.addEventListener('input', function (ev) {
|
||||||
|
var el = ev.target;
|
||||||
|
if (!el.classList || !el.classList.contains('log-search')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var step = el.closest('.step');
|
||||||
|
if (!step) { return; }
|
||||||
|
var query = el.value.trim().toLowerCase();
|
||||||
|
step.querySelectorAll('.log-line').forEach(function (line) {
|
||||||
|
if (!query) {
|
||||||
|
line.style.display = '';
|
||||||
|
line.classList.remove('log-hit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var text = (line.textContent || '').toLowerCase();
|
||||||
|
if (text.indexOf(query) === -1) {
|
||||||
|
line.style.display = 'none';
|
||||||
|
line.classList.remove('log-hit');
|
||||||
|
} else {
|
||||||
|
line.style.display = '';
|
||||||
|
line.classList.add('log-hit');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 3. permalink scroll + highlight on load ------------------------
|
||||||
|
|
||||||
|
function scrollToHash() {
|
||||||
|
var hash = (location.hash || '').replace(/^#/, '');
|
||||||
|
if (!hash) { return; }
|
||||||
|
var target = document.getElementById(hash);
|
||||||
|
if (!target) { return; }
|
||||||
|
// Open the enclosing step so the target is actually visible.
|
||||||
|
var step = target.closest('.step');
|
||||||
|
if (step && !step.open) { step.open = true; }
|
||||||
|
target.scrollIntoView({ block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', scrollToHash);
|
||||||
|
window.addEventListener('hashchange', scrollToHash);
|
||||||
|
|
||||||
|
// Anchor clicks update location.hash without triggering navigation;
|
||||||
|
// the hashchange listener above handles the scroll + highlight.
|
||||||
|
document.body.addEventListener('click', function (ev) {
|
||||||
|
var a = ev.target.closest && ev.target.closest('.log-anchor');
|
||||||
|
if (!a) { return; }
|
||||||
|
ev.preventDefault();
|
||||||
|
var href = a.getAttribute('href') || '';
|
||||||
|
if (href.indexOf('#') === 0) {
|
||||||
|
history.replaceState(null, '', href);
|
||||||
|
scrollToHash();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActiveStepData is the per-stage payload for the expanded step panel.
|
||||||
|
// The handler builds one per stage in DefaultStageOrder and hands it to
|
||||||
|
// ActiveStep so the template stays free of any slicing logic.
|
||||||
|
type ActiveStepData struct {
|
||||||
|
RunID int64
|
||||||
|
Stage model.Stage
|
||||||
|
SubSteps []model.SubStep
|
||||||
|
LogReplay string
|
||||||
|
Open bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveStep renders one stage's expanded panel: the header summary
|
||||||
|
// (state badge, stage name, duration), any sub-step rows, a per-step
|
||||||
|
// search box, and a live log pane scoped to that stage's SSE topic.
|
||||||
|
// Uses <details open?={ d.Open }> so the server-picked default stage
|
||||||
|
// opens automatically on page load; app.js takes over after that for
|
||||||
|
// SSE-driven auto-advance.
|
||||||
|
templ ActiveStep(d ActiveStepData) {
|
||||||
|
<details class={ "step", "step-" + string(d.Stage.State) } open?={ d.Open } data-stage={ d.Stage.Name }>
|
||||||
|
<summary class="step-summary">
|
||||||
|
<span class={ "stage-dot", "stage-dot-" + string(d.Stage.State) }>{ stageMarker(string(d.Stage.State)) }</span>
|
||||||
|
<span class="step-name">{ d.Stage.Name }</span>
|
||||||
|
<span class="step-duration">{ stageDurationFromStage(d.Stage) }</span>
|
||||||
|
</summary>
|
||||||
|
<div class="step-body">
|
||||||
|
if len(d.SubSteps) > 0 {
|
||||||
|
<ol class="substep-list">
|
||||||
|
for _, ss := range d.SubSteps {
|
||||||
|
@SubStepRow(ss)
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
}
|
||||||
|
<div class="log-search-wrap">
|
||||||
|
<input class="log-search" type="search" placeholder="Search this step" data-step={ d.Stage.Name }/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="log-pane"
|
||||||
|
id={ fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name) }
|
||||||
|
sse-swap={ fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name) }
|
||||||
|
hx-swap="beforeend show:bottom"
|
||||||
|
>
|
||||||
|
@templ.Raw(d.LogReplay)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubStepsForStage filters a flat []SubStep to just the entries for one
|
||||||
|
// stage. Used by host_detail when wiring ActiveStepData — keeps the
|
||||||
|
// filtering logic testable and off the template surface.
|
||||||
|
func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
|
||||||
|
out := make([]model.SubStep, 0, len(all))
|
||||||
|
for _, ss := range all {
|
||||||
|
if ss.StageName == stageName {
|
||||||
|
out = append(out, ss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
|
||||||
|
// formatting rules, different input shape.
|
||||||
|
func stageDurationFromStage(s model.Stage) string {
|
||||||
|
if s.StartedAt == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
if s.CompletedAt != nil {
|
||||||
|
end = *s.CompletedAt
|
||||||
|
}
|
||||||
|
d := end.Sub(*s.StartedAt)
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d < time.Second:
|
||||||
|
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||||
|
case d < 10*time.Second:
|
||||||
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||||
|
case d < time.Minute:
|
||||||
|
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
// 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 (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActiveStepData is the per-stage payload for the expanded step panel.
|
||||||
|
// The handler builds one per stage in DefaultStageOrder and hands it to
|
||||||
|
// ActiveStep so the template stays free of any slicing logic.
|
||||||
|
type ActiveStepData struct {
|
||||||
|
RunID int64
|
||||||
|
Stage model.Stage
|
||||||
|
SubSteps []model.SubStep
|
||||||
|
LogReplay string
|
||||||
|
Open bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveStep renders one stage's expanded panel: the header summary
|
||||||
|
// (state badge, stage name, duration), any sub-step rows, a per-step
|
||||||
|
// search box, and a live log pane scoped to that stage's SSE topic.
|
||||||
|
// Uses <details open?={ d.Open }> so the server-picked default stage
|
||||||
|
// opens automatically on page load; app.js takes over after that for
|
||||||
|
// SSE-driven auto-advance.
|
||||||
|
func ActiveStep(d ActiveStepData) 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{"step", "step-" + string(d.Stage.State)}
|
||||||
|
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, "<details class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, 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/active_step.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, "\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if d.Open {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " open")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " data-stage=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 28, Col: 102}
|
||||||
|
}
|
||||||
|
_, 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, 5, "\"><summary class=\"step-summary\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 = []any{"stage-dot", "stage-dot-" + string(d.Stage.State)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<span class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var5).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, 7, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(d.Stage.State)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 30, Col: 105}
|
||||||
|
}
|
||||||
|
_, 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, 8, "</span> <span class=\"step-name\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 31, Col: 41}
|
||||||
|
}
|
||||||
|
_, 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, 9, "</span> <span class=\"step-duration\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDurationFromStage(d.Stage))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 32, Col: 64}
|
||||||
|
}
|
||||||
|
_, 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, 10, "</span></summary><div class=\"step-body\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(d.SubSteps) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<ol class=\"substep-list\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, ss := range d.SubSteps {
|
||||||
|
templ_7745c5c3_Err = SubStepRow(ss).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</ol>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"log-search-wrap\"><input class=\"log-search\" type=\"search\" placeholder=\"Search this step\" data-step=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(d.Stage.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 43, Col: 99}
|
||||||
|
}
|
||||||
|
_, 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, 14, "\"></div><div class=\"log-pane\" id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 47, Col: 56}
|
||||||
|
}
|
||||||
|
_, 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, 15, "\" sse-swap=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", d.RunID, d.Stage.Name))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/active_step.templ`, Line: 48, Col: 62}
|
||||||
|
}
|
||||||
|
_, 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, 16, "\" hx-swap=\"beforeend show:bottom\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ.Raw(d.LogReplay).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div></div></details>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubStepsForStage filters a flat []SubStep to just the entries for one
|
||||||
|
// stage. Used by host_detail when wiring ActiveStepData — keeps the
|
||||||
|
// filtering logic testable and off the template surface.
|
||||||
|
func SubStepsForStage(all []model.SubStep, stageName string) []model.SubStep {
|
||||||
|
out := make([]model.SubStep, 0, len(all))
|
||||||
|
for _, ss := range all {
|
||||||
|
if ss.StageName == stageName {
|
||||||
|
out = append(out, ss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageDurationFromStage is stageDuration adapted to a model.Stage — same
|
||||||
|
// formatting rules, different input shape.
|
||||||
|
func stageDurationFromStage(s model.Stage) string {
|
||||||
|
if s.StartedAt == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
if s.CompletedAt != nil {
|
||||||
|
end = *s.CompletedAt
|
||||||
|
}
|
||||||
|
d := end.Sub(*s.StartedAt)
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d < time.Second:
|
||||||
|
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||||
|
case d < 10*time.Second:
|
||||||
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||||
|
case d < time.Minute:
|
||||||
|
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -9,10 +9,14 @@ import (
|
|||||||
// TileData pairs a host with its latest run and the derived fields the
|
// TileData pairs a host with its latest run and the derived fields the
|
||||||
// tile needs to render: spec-diff count (server-side diff result) and
|
// tile needs to render: spec-diff count (server-side diff result) and
|
||||||
// the on-disk path to the hold-key artifact when the run is holding.
|
// the on-disk path to the hold-key artifact when the run is holding.
|
||||||
// LastSeenAt is the host-mode agent's most recent heartbeat.
|
// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is
|
||||||
|
// the list of canonical-order stage rows for Latest, used by HostTile
|
||||||
|
// to render the mini run-view; nil/empty for never-run hosts (a ghost
|
||||||
|
// dot strip is rendered from DefaultStageOrder).
|
||||||
type TileData struct {
|
type TileData struct {
|
||||||
Host model.Host
|
Host model.Host
|
||||||
Latest *model.Run
|
Latest *model.Run
|
||||||
|
Stages []model.Stage
|
||||||
SpecDiffCritical int
|
SpecDiffCritical int
|
||||||
HoldKeyPath string
|
HoldKeyPath string
|
||||||
LastSeenAt *time.Time
|
LastSeenAt *time.Time
|
||||||
|
|||||||
@@ -17,10 +17,14 @@ import (
|
|||||||
// TileData pairs a host with its latest run and the derived fields the
|
// TileData pairs a host with its latest run and the derived fields the
|
||||||
// tile needs to render: spec-diff count (server-side diff result) and
|
// tile needs to render: spec-diff count (server-side diff result) and
|
||||||
// the on-disk path to the hold-key artifact when the run is holding.
|
// the on-disk path to the hold-key artifact when the run is holding.
|
||||||
// LastSeenAt is the host-mode agent's most recent heartbeat.
|
// LastSeenAt is the host-mode agent's most recent heartbeat. Stages is
|
||||||
|
// the list of canonical-order stage rows for Latest, used by HostTile
|
||||||
|
// to render the mini run-view; nil/empty for never-run hosts (a ghost
|
||||||
|
// dot strip is rendered from DefaultStageOrder).
|
||||||
type TileData struct {
|
type TileData struct {
|
||||||
Host model.Host
|
Host model.Host
|
||||||
Latest *model.Run
|
Latest *model.Run
|
||||||
|
Stages []model.Stage
|
||||||
SpecDiffCritical int
|
SpecDiffCritical int
|
||||||
HoldKeyPath string
|
HoldKeyPath string
|
||||||
LastSeenAt *time.Time
|
LastSeenAt *time.Time
|
||||||
|
|||||||
@@ -4,34 +4,56 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
"vetting/internal/store"
|
"vetting/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostDetailData is the full payload the detail handler hands to the
|
// HostDetailData is the full payload the detail handler hands to the
|
||||||
// HostDetail template. Tile carries host + latest-run enrichment (same
|
// HostDetail template. Tile carries host + viewed-run enrichment (same
|
||||||
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
// shape the dashboard tile uses), Stages/SpecDiffs/SubSteps drive the
|
||||||
// and diff list. LogReplay is the pre-rendered history fragment
|
// pipeline, diff list, and expanded step panel. History backs the runs
|
||||||
// produced by logs.Hub.Replay on the initial page render so the operator
|
// sidebar (last 20, newest first). DefaultStepStage is the stage name
|
||||||
// sees prior output without waiting for a fresh SSE event.
|
// whose <details> opens by default on page load — running → failed →
|
||||||
|
// Reporting. LogReplay is the pre-rendered history fragment produced
|
||||||
|
// by logs.Hub.Replay on the initial page render so the operator sees
|
||||||
|
// prior output without waiting for a fresh SSE event.
|
||||||
type HostDetailData struct {
|
type HostDetailData struct {
|
||||||
Tile TileData
|
Tile TileData
|
||||||
Stages []model.Stage
|
Stages []model.Stage
|
||||||
SpecDiffs []model.SpecDiff
|
SpecDiffs []model.SpecDiff
|
||||||
|
SubSteps []model.SubStep
|
||||||
|
History []model.Run
|
||||||
|
DefaultStepStage string
|
||||||
LogReplay string
|
LogReplay string
|
||||||
|
// LogReplayByStage is the pre-rendered log HTML grouped by stage
|
||||||
|
// name. Each ActiveStep panel picks its own bucket so the detail
|
||||||
|
// page doesn't fire nine disk scans per reload. The "" key holds
|
||||||
|
// orphan/framing lines (no stage set), surfaced under the "Run"
|
||||||
|
// pseudo-step at the top of the page.
|
||||||
|
LogReplayByStage map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HostDetail is the GitHub-Actions-style run view. Layout is: meta
|
||||||
|
// drawer (collapsed) → run header + actions → hold banner → horizontal
|
||||||
|
// pipeline → two-column body (active-step pane + runs sidebar) → spec
|
||||||
|
// diffs at the bottom. Each section keeps its own sse-swap target so
|
||||||
|
// live updates don't trigger whole-page reflows.
|
||||||
templ HostDetail(d HostDetailData) {
|
templ HostDetail(d HostDetailData) {
|
||||||
@Layout(d.Tile.Host.Name) {
|
@Layout(d.Tile.Host.Name) {
|
||||||
<section class="detail" hx-ext="sse" sse-connect="/events">
|
<section class="detail detail-v2" hx-ext="sse" sse-connect="/events">
|
||||||
<nav class="breadcrumb">
|
<nav class="breadcrumb">
|
||||||
<a href="/">Dashboard</a>
|
<a href="/">Dashboard</a>
|
||||||
<span class="breadcrumb-sep">/</span>
|
<span class="breadcrumb-sep">/</span>
|
||||||
<span>{ d.Tile.Host.Name }</span>
|
<span>{ d.Tile.Host.Name }</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
@HostMetaDrawer(d)
|
||||||
|
|
||||||
@DetailSummary(d)
|
@DetailSummary(d)
|
||||||
|
@DetailActions(d)
|
||||||
|
@DetailHold(d)
|
||||||
|
|
||||||
if d.Tile.Latest != nil {
|
if d.Tile.Latest != nil {
|
||||||
@PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages))
|
@PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages))
|
||||||
@@ -42,17 +64,51 @@ templ HostDetail(d HostDetailData) {
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@DetailHold(d)
|
<div class="detail-body">
|
||||||
@DetailActions(d)
|
<div class="active-step-pane">
|
||||||
@DetailSpecDiffs(d)
|
|
||||||
|
|
||||||
if d.Tile.Latest != nil {
|
if d.Tile.Latest != nil {
|
||||||
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
|
for _, stageName := range store.DefaultStageOrder {
|
||||||
|
@ActiveStep(ActiveStepData{
|
||||||
|
RunID: d.Tile.Latest.ID,
|
||||||
|
Stage: stageForName(d.Stages, stageName),
|
||||||
|
SubSteps: SubStepsForStage(d.SubSteps, stageName),
|
||||||
|
LogReplay: d.LogReplayByStage[stageName],
|
||||||
|
Open: stageName == d.DefaultStepStage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<p class="detail-empty">No run yet. Click <strong>Start vetting</strong> to begin.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@RunsSidebar(d)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@DetailSpecDiffs(d)
|
||||||
|
</section>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="detail-section detail-host-meta">
|
// HostMetaDrawer is the collapsed "host details" block at the top of the
|
||||||
<details>
|
// page: MAC, WoL, last-seen, expected spec, and notes. <details> defaults
|
||||||
<summary><h2>Host details</h2></summary>
|
// to closed so the run itself stays above the fold; operators open it
|
||||||
|
// when they need the provisioning info.
|
||||||
|
templ HostMetaDrawer(d HostDetailData) {
|
||||||
|
<details class="host-meta-drawer">
|
||||||
|
<summary>
|
||||||
|
<span class="meta-summary-label">Host details</span>
|
||||||
|
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
|
||||||
|
<span class="meta-summary-mac">{ d.Tile.Host.MAC }</span>
|
||||||
|
</summary>
|
||||||
|
<dl class="detail-meta">
|
||||||
|
<div>
|
||||||
|
<dt>MAC</dt>
|
||||||
|
<dd>{ d.Tile.Host.MAC }</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>WoL</dt>
|
||||||
|
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
if d.Tile.Host.Notes != "" {
|
if d.Tile.Host.Notes != "" {
|
||||||
<div class="detail-notes">
|
<div class="detail-notes">
|
||||||
<h3>Notes</h3>
|
<h3>Notes</h3>
|
||||||
@@ -64,51 +120,36 @@ templ HostDetail(d HostDetailData) {
|
|||||||
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
|
<pre class="detail-spec-yaml">{ d.Tile.Host.ExpectedSpecYAML }</pre>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetailSummary is the status header at the top of the detail page:
|
// DetailSummary is the run header: host name on the left, run number,
|
||||||
// name, last-seen badge, run status, MAC/WoL/failed-stage/spec-diffs
|
// status icon, and elapsed/total duration. Keyed on host ID so the SSE
|
||||||
// meta grid. Keyed on host ID so the SSE event name is stable across
|
// event name is stable across run turnover.
|
||||||
// run turnover.
|
|
||||||
templ DetailSummary(d HostDetailData) {
|
templ DetailSummary(d HostDetailData) {
|
||||||
<header
|
<header
|
||||||
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
id={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||||
class={ "detail-summary", "tile-" + tileMood(d.Tile.Latest) }
|
class={ "run-header", "tile-" + tileMood(d.Tile.Latest) }
|
||||||
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
sse-swap={ fmt.Sprintf("detail-summary-%d", d.Tile.Host.ID) }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<div class="detail-summary-head">
|
<div class="run-header-left">
|
||||||
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
<h1 class="detail-name">{ d.Tile.Host.Name }</h1>
|
||||||
<div class="detail-status-row">
|
if d.Tile.Latest != nil {
|
||||||
<span class={ "tile-last-seen", lastSeenClass(d.Tile.LastSeenAt) }>{ lastSeenLabel(d.Tile.LastSeenAt) }</span>
|
<span class="run-number">{ fmt.Sprintf("run #%d", d.Tile.Latest.ID) }</span>
|
||||||
<span class="tile-status">{ tileStatus(d.Tile.Latest) }</span>
|
}
|
||||||
</div>
|
<span class={ "run-status-badge", "run-status-" + tileMood(d.Tile.Latest) }>{ tileStatus(d.Tile.Latest) }</span>
|
||||||
</div>
|
if d.Tile.Latest != nil {
|
||||||
<dl class="detail-meta">
|
<span class="run-duration">{ runDuration(d.Tile.Latest) }</span>
|
||||||
<div>
|
}
|
||||||
<dt>MAC</dt>
|
|
||||||
<dd>{ d.Tile.Host.MAC }</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>WoL</dt>
|
|
||||||
<dd>{ fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort) }</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="run-header-right">
|
||||||
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
|
if d.Tile.Latest != nil && d.Tile.Latest.FailedStage != "" {
|
||||||
<div>
|
<span class="run-failed-stage">failed at <strong>{ d.Tile.Latest.FailedStage }</strong></span>
|
||||||
<dt>Failed at</dt>
|
|
||||||
<dd class="bad">{ d.Tile.Latest.FailedStage }</dd>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
if d.Tile.SpecDiffCritical > 0 {
|
if d.Tile.SpecDiffCritical > 0 {
|
||||||
<div>
|
<span class="run-diffs bad">{ fmt.Sprintf("%d critical diff", d.Tile.SpecDiffCritical) }</span>
|
||||||
<dt>Spec diffs</dt>
|
|
||||||
<dd class="bad">{ fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical) }</dd>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</dl>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +165,6 @@ templ DetailActions(d HostDetailData) {
|
|||||||
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
|
sse-swap={ fmt.Sprintf("detail-actions-%d", d.Tile.Host.ID) }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<h2>Actions</h2>
|
|
||||||
<div class="detail-actions-row">
|
<div class="detail-actions-row">
|
||||||
if canStart(d.Tile) {
|
if canStart(d.Tile) {
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
|
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)) } class="inline detail-start-form">
|
||||||
@@ -132,7 +172,7 @@ templ DetailActions(d HostDetailData) {
|
|||||||
<input type="checkbox" name="non_destructive" value="1"/>
|
<input type="checkbox" name="non_destructive" value="1"/>
|
||||||
Non-destructive (skip wipe-probe + disk writes)
|
Non-destructive (skip wipe-probe + disk writes)
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Start vetting</button>
|
<button type="submit" class="btn-primary">Start vetting</button>
|
||||||
</form>
|
</form>
|
||||||
} else if canStartIfOnline(d.Tile.Latest) {
|
} else if canStartIfOnline(d.Tile.Latest) {
|
||||||
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
|
<button type="button" disabled title="host is not heartbeating — install the reporter via /register/quick.sh on the target host">Start vetting</button>
|
||||||
@@ -141,19 +181,19 @@ templ DetailActions(d HostDetailData) {
|
|||||||
}
|
}
|
||||||
if canCancel(d.Tile.Latest) {
|
if canCancel(d.Tile.Latest) {
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');">
|
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Tile.Host.ID)) } class="inline" 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>
|
<button type="submit" class="btn-danger">Cancel run</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
if canOverrideWipe(d.Tile.Latest) {
|
if canOverrideWipe(d.Tile.Latest) {
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
|
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)) } class="inline">
|
||||||
<button type="submit" class="danger">Override wipe-probe</button>
|
<button type="submit" class="btn-danger">Override wipe-probe</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
if hasReport(d.Tile.Latest) {
|
if hasReport(d.Tile.Latest) {
|
||||||
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
<a class="button-like" href={ templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)) } target="_blank" rel="noopener">View report</a>
|
||||||
}
|
}
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline">
|
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)) } class="inline" onsubmit="return confirm('Delete host and all its runs?');">
|
||||||
<button type="submit" class="danger">Delete host</button>
|
<button type="submit" class="btn-danger">Delete host</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -192,20 +232,21 @@ templ DetailSpecDiffs(d HostDetailData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetailHold renders the "Host is holding — SSH available" block while
|
// DetailHold renders the "Host is holding — SSH available" strip across
|
||||||
// a run is in FailedHolding with an IP recorded. Otherwise it emits an
|
// the top when a run is in FailedHolding with an IP recorded. Otherwise
|
||||||
// empty wrapper so the first push when the hold actually fires has a
|
// it emits an empty wrapper so the first SSE push when the hold actually
|
||||||
// target. Keyed on run ID for the same reason as DetailSpecDiffs.
|
// fires has a target. Keyed on run ID for the same reason as
|
||||||
|
// DetailSpecDiffs.
|
||||||
templ DetailHold(d HostDetailData) {
|
templ DetailHold(d HostDetailData) {
|
||||||
if d.Tile.Latest != nil {
|
if d.Tile.Latest != nil {
|
||||||
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
||||||
<section
|
<section
|
||||||
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||||
class="detail-section detail-hold"
|
class="hold-banner"
|
||||||
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
<h2>Host is holding — SSH available</h2>
|
<span class="hold-banner-label">Host is holding — SSH available:</span>
|
||||||
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
||||||
</section>
|
</section>
|
||||||
} else {
|
} else {
|
||||||
@@ -219,6 +260,32 @@ templ DetailHold(d HostDetailData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunsSidebar is the right-rail history list: last 20 runs for this
|
||||||
|
// host, newest first. Each entry links back to /hosts/{id}?run=N for
|
||||||
|
// navigation into a past run. The row for the currently-viewed run is
|
||||||
|
// flagged so CSS can highlight it.
|
||||||
|
templ RunsSidebar(d HostDetailData) {
|
||||||
|
<aside class="runs-sidebar">
|
||||||
|
<h2 class="runs-sidebar-heading">History</h2>
|
||||||
|
if len(d.History) == 0 {
|
||||||
|
<p class="runs-sidebar-empty">No runs yet.</p>
|
||||||
|
} else {
|
||||||
|
<ul class="runs-sidebar-list">
|
||||||
|
for _, r := range d.History {
|
||||||
|
<li class={ "runs-sidebar-item", "runs-sidebar-" + tileMood(&r), runSidebarActiveClass(d.Tile.Latest, r.ID) }>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/hosts/%d?run=%d", d.Tile.Host.ID, r.ID)) }>
|
||||||
|
<span class={ "runs-sidebar-dot", "runs-sidebar-dot-" + tileMood(&r) }>{ runSidebarGlyph(&r) }</span>
|
||||||
|
<span class="runs-sidebar-id">{ fmt.Sprintf("#%d", r.ID) }</span>
|
||||||
|
<span class="runs-sidebar-started">{ relativeTime(r.StartedAt) }</span>
|
||||||
|
<span class="runs-sidebar-duration">{ runDuration(&r) }</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
|
||||||
// RenderDetailSummaryString, RenderDetailActionsString,
|
// RenderDetailSummaryString, RenderDetailActionsString,
|
||||||
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
|
// RenderDetailSpecDiffsString, RenderDetailHoldString each render one
|
||||||
// component to a string so the orchestrator can publish SSE fragments
|
// component to a string so the orchestrator can publish SSE fragments
|
||||||
@@ -259,37 +326,98 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
|
// stageForName returns the persisted Stage row for a given name, or a
|
||||||
// Switching is pure CSS: hidden radio inputs drive sibling-selector
|
// synthetic pending-state stub when no row has been seeded yet (e.g.
|
||||||
// visibility on the panes. Each pane carries its own sse-swap target so
|
// the run is still in a pre-stage). Keeps the template free of nil
|
||||||
// live events append only to the relevant pane. The All pane is seeded
|
// checks and ghost logic — ActiveStep always gets a concrete Stage.
|
||||||
// with replay HTML so reload on an in-flight run still shows history.
|
func stageForName(stages []model.Stage, name string) model.Stage {
|
||||||
templ LogTabs(runID int64, replay string) {
|
for _, s := range stages {
|
||||||
<section class="detail-section log-section">
|
if s.Name == name {
|
||||||
<h2>Log</h2>
|
return s
|
||||||
<div class="log-tabs">
|
|
||||||
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-input log-tab-all" checked/>
|
|
||||||
<label for={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-label">All</label>
|
|
||||||
for _, s := range store.DefaultStageOrder {
|
|
||||||
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class={ "log-tab-input", "log-tab-" + s }/>
|
|
||||||
<label for={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class="log-tab-label">{ s }</label>
|
|
||||||
}
|
}
|
||||||
<div
|
|
||||||
class="log-pane log-pane-all"
|
|
||||||
id={ fmt.Sprintf("log-%d", runID) }
|
|
||||||
sse-swap={ fmt.Sprintf("log-%d", runID) }
|
|
||||||
hx-swap="beforeend show:bottom"
|
|
||||||
>
|
|
||||||
@templ.Raw(replay)
|
|
||||||
</div>
|
|
||||||
for _, s := range store.DefaultStageOrder {
|
|
||||||
<div
|
|
||||||
class={ "log-pane", "log-pane-" + s }
|
|
||||||
id={ fmt.Sprintf("log-%d-%s", runID, s) }
|
|
||||||
sse-swap={ fmt.Sprintf("log-%d-%s", runID, s) }
|
|
||||||
hx-swap="beforeend show:bottom"
|
|
||||||
></div>
|
|
||||||
}
|
}
|
||||||
</div>
|
return model.Stage{Name: name, State: model.StagePending}
|
||||||
</section>
|
}
|
||||||
|
|
||||||
|
// runSidebarActiveClass marks the row for the currently-viewed run so
|
||||||
|
// CSS can highlight it. Empty string (no class added) when the row isn't
|
||||||
|
// the active one.
|
||||||
|
func runSidebarActiveClass(viewed *model.Run, rowID int64) string {
|
||||||
|
if viewed != nil && viewed.ID == rowID {
|
||||||
|
return "runs-sidebar-active"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDuration formats the elapsed time for a run using the same buckets
|
||||||
|
// as stageDuration. In-flight runs clock from StartedAt to now so the
|
||||||
|
// header duration keeps updating on each SSE tick.
|
||||||
|
func runDuration(r *model.Run) string {
|
||||||
|
if r == nil || r.StartedAt.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
if r.CompletedAt != nil {
|
||||||
|
end = *r.CompletedAt
|
||||||
|
}
|
||||||
|
d := end.Sub(r.StartedAt)
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d < time.Second:
|
||||||
|
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||||
|
case d < 10*time.Second:
|
||||||
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||||
|
case d < time.Minute:
|
||||||
|
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm %ds", int(d/time.Minute), int((d%time.Minute)/time.Second))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dh %dm", int(d/time.Hour), int((d%time.Hour)/time.Minute))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// relativeTime renders a past time as "2m ago" / "1h ago" / "3d ago"
|
||||||
|
// for the runs-sidebar. Future times (clock skew on the host) render as
|
||||||
|
// "now" so the sidebar never shows nonsense.
|
||||||
|
func relativeTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
d := time.Since(t)
|
||||||
|
if d < 0 {
|
||||||
|
return "now"
|
||||||
|
}
|
||||||
|
if d < time.Minute {
|
||||||
|
return "just now"
|
||||||
|
}
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSidebarGlyph mirrors stageMarker for run-state: ✓ / ! / ● / –.
|
||||||
|
// Used inside the sidebar dot so the color + glyph carry redundant
|
||||||
|
// meaning.
|
||||||
|
func runSidebarGlyph(r *model.Run) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch r.State {
|
||||||
|
case model.StateCompleted:
|
||||||
|
return "✓"
|
||||||
|
case model.StateFailed, model.StateFailedHolding:
|
||||||
|
return "!"
|
||||||
|
case model.StateReleased, model.StateCancelled:
|
||||||
|
return "–"
|
||||||
|
}
|
||||||
|
if r.State.IsTerminal() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "●"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostTile renders a single dashboard card. The whole tile is a link
|
// HostTile renders a single dashboard card as a mini run-view. The whole
|
||||||
// to /hosts/{id} (via a CSS-overlay <a>) — every control beyond the one
|
// tile is a link to /hosts/{id} (via a CSS-overlay <a>) — every control
|
||||||
// primary action lives on the detail page. It's the SSE-swap target
|
// beyond the one primary action lives on the detail page. It's the SSE-
|
||||||
// for per-host tile refreshes (`tile-N`).
|
// swap target for per-host tile refreshes (`tile-N`). The step list is
|
||||||
|
// a compact vertical strip of the 9 canonical stages with just a
|
||||||
|
// coloured dot per stage; operators can read run health at a glance
|
||||||
|
// across the whole dashboard without drilling in.
|
||||||
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) }
|
||||||
@@ -27,6 +32,17 @@ templ HostTile(t TileData) {
|
|||||||
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
<div class="tile-status">{ tileStatus(t.Latest) }</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
if t.Latest != nil {
|
||||||
|
<div class="tile-meta-row">
|
||||||
|
<span class="tile-run-id">{ fmt.Sprintf("#%d", t.Latest.ID) }</span>
|
||||||
|
<span class="tile-run-duration">{ runDuration(t.Latest) }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<ol class="tile-steplist">
|
||||||
|
for _, name := range store.DefaultStageOrder {
|
||||||
|
@tileStep(stageForName(t.Stages, name))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
<div class="tile-primary-action">
|
<div class="tile-primary-action">
|
||||||
if canStart(t) {
|
if canStart(t) {
|
||||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
|
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)) } class="inline tile-start-form">
|
||||||
@@ -49,6 +65,17 @@ templ HostTile(t TileData) {
|
|||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tileStep renders one entry of the tile's mini step-list: a small
|
||||||
|
// coloured dot plus the short stage name. Kept as its own templ so the
|
||||||
|
// markup stays consistent with the detail page's larger stage-dot
|
||||||
|
// elements (same class prefix, different size via the `-sm` modifier).
|
||||||
|
templ tileStep(s model.Stage) {
|
||||||
|
<li class={ "tile-step", "tile-step-" + string(s.State) }>
|
||||||
|
<span class={ "stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State) }>{ stageMarker(string(s.State)) }</span>
|
||||||
|
<span class="tile-step-name">{ s.Name }</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
func canOverrideWipe(r *model.Run) bool {
|
func canOverrideWipe(r *model.Run) bool {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -13,13 +13,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostTile renders a single dashboard card. The whole tile is a link
|
// HostTile renders a single dashboard card as a mini run-view. The whole
|
||||||
// to /hosts/{id} (via a CSS-overlay <a>) — every control beyond the one
|
// tile is a link to /hosts/{id} (via a CSS-overlay <a>) — every control
|
||||||
// primary action lives on the detail page. It's the SSE-swap target
|
// beyond the one primary action lives on the detail page. It's the SSE-
|
||||||
// for per-host tile refreshes (`tile-N`).
|
// swap target for per-host tile refreshes (`tile-N`). The step list is
|
||||||
|
// a compact vertical strip of the 9 canonical stages with just a
|
||||||
|
// coloured dot per stage; operators can read run health at a glance
|
||||||
|
// across the whole dashboard without drilling in.
|
||||||
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) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
@@ -53,7 +58,7 @@ func HostTile(t TileData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID))
|
templ_7745c5c3_Var3, 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: 17, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 22, Col: 40}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -79,7 +84,7 @@ func HostTile(t TileData) templ.Component {
|
|||||||
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(fmt.Sprintf("tile-%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: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 46}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -92,7 +97,7 @@ func HostTile(t TileData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 templ.SafeURL
|
var templ_7745c5c3_Var6 templ.SafeURL
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)))
|
templ_7745c5c3_Var6, 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: 22, Col: 80}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 80}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -105,7 +110,7 @@ func HostTile(t TileData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name)
|
templ_7745c5c3_Var7, 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: 22, Col: 117}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 27, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -118,7 +123,7 @@ func HostTile(t TileData) templ.Component {
|
|||||||
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(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: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 39}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -153,7 +158,7 @@ func HostTile(t TileData) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
|
||||||
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: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 31, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -166,77 +171,222 @@ func HostTile(t TileData) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
||||||
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: 27, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 32, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
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, 11, "</div></div></header><div class=\"tile-primary-action\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div></header>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if canStart(t) {
|
if t.Latest != nil {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<form method=\"post\" action=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"tile-meta-row\"><span class=\"tile-run-id\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 templ.SafeURL
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", t.Latest.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: 32, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 37, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
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, 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>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> <span class=\"tile-run-duration\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else if canStartIfOnline(t.Latest) {
|
var templ_7745c5c3_Var14 string
|
||||||
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>")
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(t.Latest))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 38, Col: 59}
|
||||||
}
|
|
||||||
} 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: 42, Col: 90}
|
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
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, 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>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else if hasReport(t.Latest) {
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a class=\"button-like\" href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<ol class=\"tile-steplist\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, name := range store.DefaultStageOrder {
|
||||||
|
templ_7745c5c3_Err = tileStep(stageForName(t.Stages, name)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</ol><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, 17, "<form method=\"post\" action=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 templ.SafeURL
|
var templ_7745c5c3_Var15 templ.SafeURL
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", 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: 46, Col: 88}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
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, 18, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" 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, 19, "<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, 20, "<form method=\"post\" action=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var16, 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: 58, Col: 90}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" 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, 22, "<a class=\"button-like\" href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var17, 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: 62, Col: 88}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" target=\"_blank\" rel=\"noopener\">View report</a>")
|
||||||
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, 19, "</div></article>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></article>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// tileStep renders one entry of the tile's mini step-list: a small
|
||||||
|
// coloured dot plus the short stage name. Kept as its own templ so the
|
||||||
|
// markup stays consistent with the detail page's larger stage-dot
|
||||||
|
// elements (same class prefix, different size via the `-sm` modifier).
|
||||||
|
func tileStep(s model.Stage) 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_Var18 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var18 == nil {
|
||||||
|
templ_7745c5c3_Var18 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
var templ_7745c5c3_Var19 = []any{"tile-step", "tile-step-" + string(s.State)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<li class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var19).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_Var20))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 = []any{"stage-dot", "stage-dot-sm", "stage-dot-" + string(s.State)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var21).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_Var22))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(string(s.State)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 74, Col: 108}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span> <span class=\"tile-step-name\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 75, Col: 39}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></li>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package templates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHumanAgoFrom(t *testing.T) {
|
func TestHumanAgoFrom(t *testing.T) {
|
||||||
@@ -96,6 +98,99 @@ func TestHostTile_DisabledStartWhenOffline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHostTile_MiniRunView asserts the tile renders a step-list entry
|
||||||
|
// for every canonical stage, colours the dots according to the mixed
|
||||||
|
// stage states in the fixture, and surfaces the run id + duration in
|
||||||
|
// the meta row. This is the contract the dashboard leans on: the
|
||||||
|
// operator should be able to read run health across all tiles without
|
||||||
|
// drilling into any of them.
|
||||||
|
func TestHostTile_MiniRunView(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
started := now.Add(-3 * time.Minute)
|
||||||
|
latest := &model.Run{
|
||||||
|
ID: 17,
|
||||||
|
State: model.StateSMART,
|
||||||
|
StartedAt: started,
|
||||||
|
}
|
||||||
|
// Mixed states: first two stages passed, SMART running, rest pending.
|
||||||
|
stages := []model.Stage{
|
||||||
|
{Name: "Inventory", State: model.StagePassed},
|
||||||
|
{Name: "SpecValidate", State: model.StagePassed},
|
||||||
|
{Name: "SMART", State: model.StageRunning},
|
||||||
|
}
|
||||||
|
data := TileData{
|
||||||
|
Host: model.Host{ID: 42, Name: "tile-test", MAC: "aa:bb:cc:dd:ee:ff"},
|
||||||
|
Latest: latest,
|
||||||
|
Stages: stages,
|
||||||
|
LastSeenAt: &now,
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
html := buf.String()
|
||||||
|
|
||||||
|
// Step list exists and contains every canonical stage name so the
|
||||||
|
// operator reads a full 9-dot strip regardless of how far the run got.
|
||||||
|
if !strings.Contains(html, `<ol class="tile-steplist">`) {
|
||||||
|
t.Fatalf("tile missing step list: %s", html)
|
||||||
|
}
|
||||||
|
for _, s := range store.DefaultStageOrder {
|
||||||
|
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, s)
|
||||||
|
if !strings.Contains(html, want) {
|
||||||
|
t.Fatalf("tile missing step name %q: %s", s, html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Colours: the two passed stages got passed dots; SMART got a running
|
||||||
|
// dot; CPUStress (no fixture row) falls back to pending.
|
||||||
|
mustContain := []string{
|
||||||
|
`stage-dot stage-dot-sm stage-dot-passed`,
|
||||||
|
`stage-dot stage-dot-sm stage-dot-running`,
|
||||||
|
`stage-dot stage-dot-sm stage-dot-pending`,
|
||||||
|
}
|
||||||
|
for _, c := range mustContain {
|
||||||
|
if !strings.Contains(html, c) {
|
||||||
|
t.Fatalf("tile missing expected dot classes %q: %s", c, html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Meta row: run id + a duration string (minutes for a 3m-old run).
|
||||||
|
if !strings.Contains(html, `#17`) {
|
||||||
|
t.Fatalf("tile missing run id #17: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `class="tile-run-duration"`) {
|
||||||
|
t.Fatalf("tile missing duration element: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostTile_GhostSteplist: a never-run host still gets a 9-dot
|
||||||
|
// ghost strip (all pending). Keeps the tile height stable so the
|
||||||
|
// dashboard grid doesn't reflow as hosts gain their first run.
|
||||||
|
func TestHostTile_GhostSteplist(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
data := TileData{
|
||||||
|
Host: model.Host{ID: 1, Name: "fresh", MAC: "aa:bb:cc:dd:ee:01"},
|
||||||
|
LastSeenAt: &now,
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := HostTile(data).Render(context.Background(), &buf); err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
html := buf.String()
|
||||||
|
for _, s := range store.DefaultStageOrder {
|
||||||
|
want := fmt.Sprintf(`<span class="tile-step-name">%s</span>`, s)
|
||||||
|
if !strings.Contains(html, want) {
|
||||||
|
t.Fatalf("ghost tile missing stage %q: %s", s, html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(html, `stage-dot-passed`) || strings.Contains(html, `stage-dot-running`) || strings.Contains(html, `stage-dot-failed`) {
|
||||||
|
t.Fatalf("ghost tile should have only pending dots: %s", html)
|
||||||
|
}
|
||||||
|
// No run → no meta row (suppresses "#0 · 0s" when no run exists).
|
||||||
|
if strings.Contains(html, `class="tile-run-id"`) {
|
||||||
|
t.Fatalf("ghost tile should omit run id: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLastSeenLabelAndClass(t *testing.T) {
|
func TestLastSeenLabelAndClass(t *testing.T) {
|
||||||
if got := lastSeenLabel(nil); got != "never" {
|
if got := lastSeenLabel(nil); got != "never" {
|
||||||
t.Fatalf("label nil = %q, want never", got)
|
t.Fatalf("label nil = %q, want never", got)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ templ Layout(title string) {
|
|||||||
<link rel="stylesheet" href="/static/app.css"/>
|
<link rel="stylesheet" href="/static/app.css"/>
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.2" integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ" crossorigin="anonymous"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.2" integrity="sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ" crossorigin="anonymous"></script>
|
||||||
<script src="https://unpkg.com/htmx-ext-sse@2.2.2" integrity="sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI" crossorigin="anonymous"></script>
|
<script src="https://unpkg.com/htmx-ext-sse@2.2.2" integrity="sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI" crossorigin="anonymous"></script>
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body hx-boost="true">
|
<body hx-boost="true">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func Layout(title string) templ.Component {
|
|||||||
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, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting</title><link rel=\"stylesheet\" href=\"/static/app.css\"><script src=\"https://unpkg.com/htmx.org@2.0.2\" integrity=\"sha384-Y7hw+L/jvKeWIRRkqWYfPcvVxHzVzn5REgzbawhxAuQGwX1XWe70vji+VSeHOThJ\" crossorigin=\"anonymous\"></script><script src=\"https://unpkg.com/htmx-ext-sse@2.2.2\" integrity=\"sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI\" crossorigin=\"anonymous\"></script><script src=\"/static/app.js\" defer></script></head><body hx-boost=\"true\"><header class=\"topbar\"><div class=\"brand\">Vetting</div><nav><a href=\"/\">Dashboard</a> <a href=\"/hosts/new\">Register host</a></nav><div class=\"session\"><span class=\"heartbeat\" hx-ext=\"sse\" sse-connect=\"/events\" sse-swap=\"heartbeat\">·</span></div></header><main>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 38, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 39, Col: 17}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
|
|||||||
@@ -14,20 +14,24 @@ type RegistrationForm struct {
|
|||||||
templ Registration(form RegistrationForm) {
|
templ Registration(form RegistrationForm) {
|
||||||
@Layout("Register host") {
|
@Layout("Register host") {
|
||||||
<section class="form-wrap">
|
<section class="form-wrap">
|
||||||
|
<header class="form-wrap-head">
|
||||||
<h1>Register host</h1>
|
<h1>Register host</h1>
|
||||||
|
<a class="button-secondary" href="/">Back to dashboard</a>
|
||||||
|
</header>
|
||||||
if form.Error != "" {
|
if form.Error != "" {
|
||||||
<div class="error">{ form.Error }</div>
|
<div class="error">{ form.Error }</div>
|
||||||
}
|
}
|
||||||
if form.QuickRegisterURL != "" {
|
if form.QuickRegisterURL != "" {
|
||||||
<div class="quick-register">
|
<section class="detail-section quick-register">
|
||||||
<h2>Quick register <span class="muted">(recommended)</span></h2>
|
<h2>Quick register <span class="muted">(recommended)</span></h2>
|
||||||
<p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p>
|
<p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p>
|
||||||
<pre class="one-liner"><code>{ "curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash" }</code></pre>
|
<pre class="one-liner"><code>{ "curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash" }</code></pre>
|
||||||
<p class="muted">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p>
|
<p class="muted">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p>
|
||||||
</div>
|
</section>
|
||||||
}
|
}
|
||||||
|
<section class="detail-section manual-register-card">
|
||||||
<details class="manual-register">
|
<details class="manual-register">
|
||||||
<summary>Register manually</summary>
|
<summary><h2>Register manually</h2></summary>
|
||||||
<form method="post" action="/hosts" class="host-form">
|
<form method="post" action="/hosts" class="host-form">
|
||||||
<label>
|
<label>
|
||||||
Name
|
Name
|
||||||
@@ -56,12 +60,13 @@ templ Registration(form RegistrationForm) {
|
|||||||
<textarea name="notes" rows="3">{ form.Notes }</textarea>
|
<textarea name="notes" rows="3">{ form.Notes }</textarea>
|
||||||
</label>
|
</label>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Register</button>
|
<button type="submit" class="btn-primary">Register</button>
|
||||||
<a class="button-secondary" href="/">Cancel</a>
|
<a class="button-secondary" href="/">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"form-wrap\"><h1>Register host</h1>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"form-wrap\"><header class=\"form-wrap-head\"><h1>Register host</h1><a class=\"button-secondary\" href=\"/\">Back to dashboard</a></header>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 19, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 22, Col: 35}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -76,32 +76,32 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if form.QuickRegisterURL != "" {
|
if form.QuickRegisterURL != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div class=\"quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><pre class=\"one-liner\"><code>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<section class=\"detail-section quick-register\"><h2>Quick register <span class=\"muted\">(recommended)</span></h2><p>Run this on the target host as root before wiping. It auto-detects MAC and hardware, then registers with this orchestrator:</p><pre class=\"one-liner\"><code>")
|
||||||
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 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 25, Col: 108}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 28, Col: 108}
|
||||||
}
|
}
|
||||||
_, 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, 5, "</code></pre><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</code></pre><p class=\"muted\">After the script prints <code>OK</code>, refresh the dashboard and click <b>Start vetting</b> on the new host.</p></section>")
|
||||||
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, "<details class=\"manual-register\"><summary>Register manually</summary><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<section class=\"detail-section manual-register-card\"><details class=\"manual-register\"><summary><h2>Register manually</h2></summary><form method=\"post\" action=\"/hosts\" class=\"host-form\"><label>Name <input type=\"text\" name=\"name\" value=\"")
|
||||||
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(form.Name)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 34, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 55}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -114,7 +114,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 52}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 42, Col: 53}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -127,7 +127,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 43, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 78}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -140,7 +140,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 51, Col: 78}
|
||||||
}
|
}
|
||||||
_, 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 {
|
||||||
@@ -153,7 +153,7 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 52, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 127}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -166,13 +166,13 @@ func Registration(form RegistrationForm) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 60, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
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, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</textarea></label><div class=\"actions\"><button type=\"submit\" class=\"btn-primary\">Register</button> <a class=\"button-secondary\" href=\"/\">Cancel</a></div></form></details></section></section>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"vetting/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// subStepDuration formats a sub-step's elapsed time the same way
|
||||||
|
// stageDuration does for pipeline nodes. Empty string when not started.
|
||||||
|
func subStepDuration(ss model.SubStep) string {
|
||||||
|
if ss.StartedAt == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
if ss.CompletedAt != nil {
|
||||||
|
end = *ss.CompletedAt
|
||||||
|
}
|
||||||
|
d := end.Sub(*ss.StartedAt)
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d < time.Second:
|
||||||
|
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||||
|
case d < 10*time.Second:
|
||||||
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||||
|
case d < time.Minute:
|
||||||
|
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
|
||||||
|
// state badge. StageState values reused verbatim for sub-steps.
|
||||||
|
func subStepMarker(s model.StageState) string {
|
||||||
|
switch s {
|
||||||
|
case model.StagePassed:
|
||||||
|
return "✓"
|
||||||
|
case model.StageFailed:
|
||||||
|
return "!"
|
||||||
|
case model.StageRunning:
|
||||||
|
return "●"
|
||||||
|
case model.StageSkipped:
|
||||||
|
return "–"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubStepRow renders one sub-step entry for the expanded-step pane. The
|
||||||
|
// outer <div> carries the sse-swap target keyed by (runID, stage,
|
||||||
|
// ordinal) so the orchestrator's PublishSubStepUpdate can swap just this
|
||||||
|
// row without touching the rest of the stage panel. hx-swap="outerHTML"
|
||||||
|
// keeps the attributes intact across repeat swaps.
|
||||||
|
templ SubStepRow(ss model.SubStep) {
|
||||||
|
<div
|
||||||
|
id={ fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal) }
|
||||||
|
class={ "substep", "substep-" + string(ss.State) }
|
||||||
|
sse-swap={ fmt.Sprintf("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal) }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<span class={ "substep-badge", "substep-badge-" + string(ss.State) }>{ subStepMarker(ss.State) }</span>
|
||||||
|
<span class="substep-name">{ ss.Name }</span>
|
||||||
|
<span class="substep-duration">{ subStepDuration(ss) }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSubStepRowString is the one-shot renderer the orchestrator
|
||||||
|
// registers as SubStepRenderer so it can emit substep-* SSE payloads
|
||||||
|
// without importing the templates package directly.
|
||||||
|
func RenderSubStepRowString(ss model.SubStep) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = SubStepRow(ss).Render(context.Background(), &buf)
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// subStepDuration formats a sub-step's elapsed time the same way
|
||||||
|
// stageDuration does for pipeline nodes. Empty string when not started.
|
||||||
|
func subStepDuration(ss model.SubStep) string {
|
||||||
|
if ss.StartedAt == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
if ss.CompletedAt != nil {
|
||||||
|
end = *ss.CompletedAt
|
||||||
|
}
|
||||||
|
d := end.Sub(*ss.StartedAt)
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case d < time.Second:
|
||||||
|
return fmt.Sprintf("%dms", int(d/time.Millisecond))
|
||||||
|
case d < 10*time.Second:
|
||||||
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||||
|
case d < time.Minute:
|
||||||
|
return fmt.Sprintf("%ds", int(d/time.Second))
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm", int(d/time.Minute))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dh", int(d/time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subStepMarker mirrors stageMarker — a single-char glyph used inside the
|
||||||
|
// state badge. StageState values reused verbatim for sub-steps.
|
||||||
|
func subStepMarker(s model.StageState) string {
|
||||||
|
switch s {
|
||||||
|
case model.StagePassed:
|
||||||
|
return "✓"
|
||||||
|
case model.StageFailed:
|
||||||
|
return "!"
|
||||||
|
case model.StageRunning:
|
||||||
|
return "●"
|
||||||
|
case model.StageSkipped:
|
||||||
|
return "–"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubStepRow renders one sub-step entry for the expanded-step pane. The
|
||||||
|
// outer <div> carries the sse-swap target keyed by (runID, stage,
|
||||||
|
// ordinal) so the orchestrator's PublishSubStepUpdate can swap just this
|
||||||
|
// row without touching the rest of the stage panel. hx-swap="outerHTML"
|
||||||
|
// keeps the attributes intact across repeat swaps.
|
||||||
|
func SubStepRow(ss model.SubStep) 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{"substep", "substep-" + string(ss.State)}
|
||||||
|
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, "<div 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("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 63, Col: 74}
|
||||||
|
}
|
||||||
|
_, 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/substep_row.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("substep-%d-%s-%d", ss.RunID, ss.StageName, ss.Ordinal))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 65, Col: 80}
|
||||||
|
}
|
||||||
|
_, 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\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 = []any{"substep-badge", "substep-badge-" + string(ss.State)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(subStepMarker(ss.State))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 68, Col: 96}
|
||||||
|
}
|
||||||
|
_, 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, "</span> <span class=\"substep-name\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ss.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 69, Col: 38}
|
||||||
|
}
|
||||||
|
_, 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, "</span> <span class=\"substep-duration\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(subStepDuration(ss))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/substep_row.templ`, Line: 70, Col: 54}
|
||||||
|
}
|
||||||
|
_, 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, "</span></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSubStepRowString is the one-shot renderer the orchestrator
|
||||||
|
// registers as SubStepRenderer so it can emit substep-* SSE payloads
|
||||||
|
// without importing the templates package directly.
|
||||||
|
func RenderSubStepRowString(ss model.SubStep) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = SubStepRow(ss).Render(context.Background(), &buf)
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
Reference in New Issue
Block a user