deep profile + threshold gating + firmware stage + Burn super-stage
Ships all five phases of the deep-profile overhaul together. Runs now carry a profile (quick/deep/soak); every profile walks the same 11-stage order — Inventory → Firmware → SpecValidate → SMART → CPUStress → Storage → Network → Burn → GPU → PSU → Reporting — with only per-stage durations and concurrency scaled. Phase 1: profiles.ProfileRegistry loaded from vetting.yaml; runs.profile column + CreateWithProfile; threshold table + evaluator seeded per-run from the shared vetting.thresholds block; breach flips result at /sensor + /result. Phase 2: upgraded CPUStress (stress-ng --cpu-method=all --verify + EDAC/MCE poll), Storage (fio --verify=md5 + SMART start/end delta), Network (sustained iperf + /proc/net/dev deltas) with per-profile knobs from Deps. Phase 3: Burn super-stage with goroutine fan-out for CPU + memory + fio + iperf, PSU rails sampled across the Burn window, SensorMux (2 s flush, 500-sample cap) to absorb backpressure. Phase 4: Firmware stage + firmware_snapshots table; probes dmidecode (BIOS), ipmitool (BMC), ethtool -i (NIC), nvme (sysfs + id-ctrl), lspci (HBA), /proc/cpuinfo (microcode). spec.DiffFirmware folds into SpecValidate with pin-by-identifier and fan-out-across-component matching; mismatches park the run in FailedHolding. Phase 5: profile radio on the host start form, profile chip on the run header, Firmware section in the HTML report, coverage artifact uploaded from CI, agent/tests/fakes/ scaffold with Deps.LookPath seam + stress_ng and dmidecode example fakes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"vetting/internal/config"
|
||||
"vetting/internal/events"
|
||||
"vetting/internal/hold"
|
||||
"vetting/internal/logs"
|
||||
@@ -41,6 +42,9 @@ type Agent struct {
|
||||
Artifacts *store.Artifacts
|
||||
SpecDiffs *store.SpecDiffs
|
||||
Measurements *store.Measurements
|
||||
Thresholds *store.Thresholds // Phase 1: seeded per run; consulted on each /sensor batch
|
||||
Firmware *store.Firmware // Phase 4: firmware snapshots (unused before then)
|
||||
Profiles *config.ProfileRegistry // Phase 2: /claim resolves the run's profile → stage knobs
|
||||
Runner *orchestrator.Runner
|
||||
EventHub *events.Hub
|
||||
Logs *logs.Hub
|
||||
@@ -216,6 +220,21 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
|
||||
if iperfPort == 0 {
|
||||
iperfPort = 5201
|
||||
}
|
||||
|
||||
// Resolve the run's profile → agent-visible stage knobs. The agent
|
||||
// reads these to size CPUStress / Storage / Network work. An empty
|
||||
// profile (legacy runs seeded before Phase 1) falls back to "quick".
|
||||
profileName := run.Profile
|
||||
if profileName == "" {
|
||||
profileName = config.ProfileQuick
|
||||
}
|
||||
var stageCfg config.StageConfig
|
||||
if a.Profiles != nil {
|
||||
stageCfg = a.Profiles.ResolveStageConfig(profileName)
|
||||
} else {
|
||||
stageCfg = config.StageConfig{Profile: profileName}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"run_id": runID,
|
||||
@@ -224,6 +243,7 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
|
||||
"iperf_port": iperfPort,
|
||||
"non_destructive": run.NonDestructive,
|
||||
"current_state": string(currentState),
|
||||
"stage_config": stageCfg,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -398,10 +418,24 @@ type StageResult struct {
|
||||
Passed bool `json:"passed"`
|
||||
Summary json.RawMessage `json:"summary,omitempty"`
|
||||
Inventory *spec.Inventory `json:"inventory,omitempty"`
|
||||
Firmware []FirmwareLine `json:"firmware,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
SubSteps []SubStepResultLine `json:"sub_steps,omitempty"`
|
||||
}
|
||||
|
||||
// FirmwareLine is a single firmware snapshot POSTed alongside the
|
||||
// Firmware stage's /result body. Mirrors agent/probes.FirmwareSnapshot.
|
||||
// The server converts each line to a store.FirmwareSnapshot and persists
|
||||
// it under the run — SpecValidate reads these back to diff against the
|
||||
// host's expected_firmware.
|
||||
type FirmwareLine struct {
|
||||
Component string `json:"component"`
|
||||
Identifier string `json:"identifier"`
|
||||
Version string `json:"version"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
Raw map[string]string `json:"raw,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 {
|
||||
@@ -476,6 +510,20 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Aggregate threshold gate: flip Passed=false server-side when any
|
||||
// critical breach landed for this stage. The agent's verdict is
|
||||
// advisory — a stage-executor can miss a runaway sample that the
|
||||
// sidecar caught. We check this *before* writing the stage state
|
||||
// so the DB reflects the server-side decision.
|
||||
thresholdDetail := ""
|
||||
if body.Passed {
|
||||
if breached, detail := a.stageHadCriticalBreach(r.Context(), runID, body.Stage); breached {
|
||||
body.Passed = false
|
||||
thresholdDetail = detail
|
||||
a.appendLog(runID, "error", fmt.Sprintf("%s reported passed but %s — flipping to failed", body.Stage, detail))
|
||||
}
|
||||
}
|
||||
|
||||
stageState := model.StagePassed
|
||||
if !body.Passed {
|
||||
stageState = model.StageFailed
|
||||
@@ -488,6 +536,9 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "complete stage: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if thresholdDetail != "" && body.Message == "" {
|
||||
body.Message = thresholdDetail
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -502,6 +553,14 @@ func (a *Agent) Result(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware-specific: persist each snapshot into firmware_snapshots.
|
||||
// SpecValidate reads them back to diff against expected_firmware.
|
||||
if body.Stage == "Firmware" && len(body.Firmware) > 0 {
|
||||
if err := a.persistFirmware(r.Context(), runID, body.Firmware); err != nil {
|
||||
log.Printf("persist firmware run %d: %v", runID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !body.Passed {
|
||||
if err := a.Runs.SetFailedStage(r.Context(), runID, body.Stage); err != nil {
|
||||
log.Printf("set failed stage: %v", err)
|
||||
@@ -615,6 +674,34 @@ func parseResultTime(s string) *time.Time {
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistFirmware writes the reported snapshots. A nil/unset a.Firmware
|
||||
// store is a no-op so tests that don't wire it up stay green; a mid-run
|
||||
// persist error is logged but doesn't fail the stage (Firmware is
|
||||
// advisory — SpecValidate is the gate).
|
||||
func (a *Agent) persistFirmware(ctx context.Context, runID int64, lines []FirmwareLine) error {
|
||||
if a.Firmware == nil || len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := make([]store.FirmwareSnapshot, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
raw := "{}"
|
||||
if len(l.Raw) > 0 {
|
||||
if b, err := json.Marshal(l.Raw); err == nil {
|
||||
raw = string(b)
|
||||
}
|
||||
}
|
||||
rows = append(rows, store.FirmwareSnapshot{
|
||||
RunID: runID,
|
||||
Component: l.Component,
|
||||
Identifier: l.Identifier,
|
||||
Version: l.Version,
|
||||
Vendor: l.Vendor,
|
||||
RawJSON: raw,
|
||||
})
|
||||
}
|
||||
return a.Firmware.CreateBatch(ctx, rows)
|
||||
}
|
||||
|
||||
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))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
@@ -667,6 +754,22 @@ func (a *Agent) resolveSpecValidate(r *http.Request, runID int64) {
|
||||
return
|
||||
}
|
||||
diffs := spec.Diff(expected, inv)
|
||||
if a.Firmware != nil && len(expected.Firmware) > 0 {
|
||||
snaps, err := a.Firmware.ListForRun(r.Context(), runID)
|
||||
if err != nil {
|
||||
log.Printf("specvalidate: list firmware: %v", err)
|
||||
} else {
|
||||
observed := make([]spec.FirmwareObserved, 0, len(snaps))
|
||||
for _, s := range snaps {
|
||||
observed = append(observed, spec.FirmwareObserved{
|
||||
Component: s.Component,
|
||||
Identifier: s.Identifier,
|
||||
Version: s.Version,
|
||||
})
|
||||
}
|
||||
diffs = append(diffs, spec.DiffFirmware(expected.Firmware, observed)...)
|
||||
}
|
||||
}
|
||||
if err := a.SpecDiffs.ReplaceForRun(r.Context(), runID, diffs); err != nil {
|
||||
log.Printf("specvalidate: write diffs: %v", err)
|
||||
}
|
||||
@@ -884,13 +987,17 @@ type SensorSample struct {
|
||||
}
|
||||
|
||||
// Sensor persists a batch of numeric samples. The thermal sidecar hits
|
||||
// this on a tick; stage executors (iperf, fio) also drop here.
|
||||
// this on a tick; stage executors (iperf, fio) also drop here. Each
|
||||
// sample is evaluated against the run's seeded thresholds — critical
|
||||
// breaches fail the run immediately (thermal runaway, EDAC UE, voltage
|
||||
// sag); warning breaches are recorded for the report only.
|
||||
func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
|
||||
runID, ok := runIDFromURL(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := a.authenticate(w, r, runID); !ok {
|
||||
run, ok := a.authenticate(w, r, runID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if a.Measurements == nil {
|
||||
@@ -903,8 +1010,12 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
rows := make([]model.Measurement, 0, len(body.Samples))
|
||||
sampleStages := make([]string, 0, len(body.Samples))
|
||||
for _, s := range body.Samples {
|
||||
ts, _ := time.Parse(time.RFC3339Nano, s.TS)
|
||||
if ts.IsZero() {
|
||||
ts = time.Now().UTC()
|
||||
}
|
||||
rows = append(rows, model.Measurement{
|
||||
RunID: runID,
|
||||
TS: ts,
|
||||
@@ -913,12 +1024,139 @@ func (a *Agent) Sensor(w http.ResponseWriter, r *http.Request) {
|
||||
Value: s.Value,
|
||||
Unit: s.Unit,
|
||||
})
|
||||
// Stage the sample belongs to drives threshold selector
|
||||
// matching. We use the run's current state — the agent does
|
||||
// not tag samples with a stage.
|
||||
sampleStages = append(sampleStages, orchestrator.StageNameForState(run.State))
|
||||
}
|
||||
if err := a.Measurements.CreateBatch(r.Context(), rows); err != nil {
|
||||
http.Error(w, "write samples: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "written": len(rows)})
|
||||
critical := a.evaluateSensorBatch(r.Context(), runID, rows, sampleStages)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"written": len(rows),
|
||||
"breach": critical != "",
|
||||
"breach_kind": critical,
|
||||
})
|
||||
if critical != "" {
|
||||
a.failRunOnCriticalBreach(r, run, critical)
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateSensorBatch runs each sample through the run's thresholds,
|
||||
// persists evaluations, and returns a short human-readable label for
|
||||
// the first critical breach it sees (empty when all samples pass or
|
||||
// only hit warning-severity rules).
|
||||
func (a *Agent) evaluateSensorBatch(ctx context.Context, runID int64, rows []model.Measurement, sampleStages []string) string {
|
||||
if a.Thresholds == nil || len(rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
rules, err := a.Thresholds.ListForRun(ctx, runID)
|
||||
if err != nil {
|
||||
log.Printf("sensor: list thresholds run %d: %v", runID, err)
|
||||
return ""
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return ""
|
||||
}
|
||||
evalRules := make([]orchestrator.Threshold, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
evalRules = append(evalRules, orchestrator.Threshold{
|
||||
ID: r.ID,
|
||||
Stage: r.Stage,
|
||||
Kind: r.Kind,
|
||||
Key: r.Key,
|
||||
Op: orchestrator.ThresholdOp(r.Op),
|
||||
Value: r.Threshold,
|
||||
Nominal: r.Nominal,
|
||||
Severity: orchestrator.ThresholdSeverity(r.Severity),
|
||||
})
|
||||
}
|
||||
evals := make([]store.ThresholdEvaluation, 0, len(rows))
|
||||
critical := ""
|
||||
for i, m := range rows {
|
||||
sample := orchestrator.Sample{
|
||||
Stage: sampleStages[i],
|
||||
Kind: m.Kind,
|
||||
Key: m.Key,
|
||||
Value: m.Value,
|
||||
}
|
||||
for _, res := range orchestrator.Evaluate(sample, evalRules) {
|
||||
evals = append(evals, store.ThresholdEvaluation{
|
||||
RunID: runID,
|
||||
ThresholdID: res.Threshold.ID,
|
||||
Stage: sample.Stage,
|
||||
Kind: sample.Kind,
|
||||
Key: sample.Key,
|
||||
TS: m.TS,
|
||||
Observed: res.Observed,
|
||||
Passed: res.Passed,
|
||||
})
|
||||
if critical == "" && res.CriticalBreach() {
|
||||
critical = fmt.Sprintf("%s %s=%g breached %s %g",
|
||||
res.Threshold.Kind, sample.Key, res.Observed, res.Threshold.Op, res.Threshold.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.Thresholds.RecordBatch(ctx, evals); err != nil {
|
||||
log.Printf("sensor: record evals run %d: %v", runID, err)
|
||||
}
|
||||
return critical
|
||||
}
|
||||
|
||||
// stageHadCriticalBreach returns true if any critical-severity
|
||||
// threshold evaluation for this run matched samples attributed to the
|
||||
// given stage (stage selector "*" or exact). Called at /result close
|
||||
// so even an agent that reports Passed=true gets overridden when the
|
||||
// aggregate view says the stage tripped a gate.
|
||||
func (a *Agent) stageHadCriticalBreach(ctx context.Context, runID int64, stage string) (bool, string) {
|
||||
if a.Thresholds == nil {
|
||||
return false, ""
|
||||
}
|
||||
breaches, err := a.Thresholds.CriticalBreaches(ctx, runID)
|
||||
if err != nil {
|
||||
log.Printf("result: list breaches run %d: %v", runID, err)
|
||||
return false, ""
|
||||
}
|
||||
for _, b := range breaches {
|
||||
if b.Stage == stage || b.Stage == "" || b.Stage == "*" {
|
||||
return true, fmt.Sprintf("critical threshold breach: %s %s=%g", b.Kind, b.Key, b.Observed)
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// failRunOnCriticalBreach flips the run to FailedHolding in response
|
||||
// to a live threshold breach (thermal runaway, EDAC UE, rail sag).
|
||||
// The agent's pending /result for the current stage may still arrive —
|
||||
// the silent-skip guard handles that by refusing to double-transition.
|
||||
func (a *Agent) failRunOnCriticalBreach(r *http.Request, run *model.Run, detail string) {
|
||||
stage := orchestrator.StageNameForState(run.State)
|
||||
if stage == "" {
|
||||
stage = "threshold"
|
||||
}
|
||||
if err := a.Runs.SetFailedStage(r.Context(), run.ID, stage+" (threshold)"); err != nil {
|
||||
log.Printf("sensor: set failed stage run %d: %v", run.ID, err)
|
||||
}
|
||||
if _, err := a.Runner.Transition(r.Context(), run.ID, orchestrator.TriggerStageFailed); err != nil {
|
||||
// If we're already in FailedHolding the transition errors —
|
||||
// that's fine, the first breach wins.
|
||||
log.Printf("sensor: fail-transition run %d: %v", run.ID, err)
|
||||
return
|
||||
}
|
||||
hostName := a.hostNameFor(r.Context(), run.HostID)
|
||||
a.dispatchEvent(notify.Event{
|
||||
Kind: notify.KindStageFailed,
|
||||
Severity: notify.SeverityCritical,
|
||||
RunID: run.ID,
|
||||
HostName: hostName,
|
||||
Title: fmt.Sprintf("[vetting] %s FAILED: %s (threshold)", hostName, stage),
|
||||
Body: fmt.Sprintf("Run %d on %s tripped a critical threshold during %s: %s", run.ID, hostName, stage, detail),
|
||||
URL: a.runLinkURL(run.ID),
|
||||
})
|
||||
a.appendLog(run.ID, "error", fmt.Sprintf("threshold breach during %s: %s — run parked in FailedHolding", stage, detail))
|
||||
}
|
||||
|
||||
// resolveReporting runs when the pipeline advances into StateReporting.
|
||||
@@ -956,12 +1194,20 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) {
|
||||
log.Printf("reporting: list measurements: %v", err)
|
||||
}
|
||||
}
|
||||
var firmware []store.FirmwareSnapshot
|
||||
if a.Firmware != nil {
|
||||
firmware, err = a.Firmware.ListForRun(ctx, runID)
|
||||
if err != nil {
|
||||
log.Printf("reporting: list firmware: %v", err)
|
||||
}
|
||||
}
|
||||
bundle := map[string]any{
|
||||
"run": run,
|
||||
"host": host,
|
||||
"stages": stages,
|
||||
"spec_diffs": diffs,
|
||||
"measurements": measurements,
|
||||
"firmware": firmware,
|
||||
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
buf, err := json.MarshalIndent(bundle, "", " ")
|
||||
@@ -993,6 +1239,15 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) {
|
||||
// Also render the operator-facing HTML summary alongside the JSON.
|
||||
// Failures here are non-fatal — the JSON is the source of truth.
|
||||
if host != nil {
|
||||
fwRows := make([]report.FirmwareSnapshot, 0, len(firmware))
|
||||
for _, f := range firmware {
|
||||
fwRows = append(fwRows, report.FirmwareSnapshot{
|
||||
Component: f.Component,
|
||||
Identifier: f.Identifier,
|
||||
Version: f.Version,
|
||||
Vendor: f.Vendor,
|
||||
})
|
||||
}
|
||||
htmlData := report.Data{
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
Run: *run,
|
||||
@@ -1000,6 +1255,7 @@ func (a *Agent) resolveReporting(r *http.Request, runID int64) {
|
||||
Stages: stages,
|
||||
SpecDiffs: diffs,
|
||||
Aggregates: report.AggregateMeasurements(measurements),
|
||||
Firmware: fwRows,
|
||||
}
|
||||
if htmlBuf, err := report.RenderHTML(htmlData); err != nil {
|
||||
log.Printf("reporting: render html: %v", err)
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestRunPage_DefaultStep_Running(t *testing.T) {
|
||||
})
|
||||
runID, _ := runs.Create(ctx, id, "rr", false)
|
||||
_ = ui.Stages.Seed(ctx, runID)
|
||||
for _, name := range []string{"Inventory", "SpecValidate"} {
|
||||
for _, name := range []string{"Inventory", "Firmware", "SpecValidate"} {
|
||||
_ = ui.Stages.StartByName(ctx, runID, name)
|
||||
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func TestRunPage_DefaultStep_Failed(t *testing.T) {
|
||||
})
|
||||
runID, _ := runs.Create(ctx, id, "rf", false)
|
||||
_ = ui.Stages.Seed(ctx, runID)
|
||||
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
|
||||
for _, name := range []string{"Inventory", "Firmware", "SpecValidate", "SMART"} {
|
||||
_ = ui.Stages.StartByName(ctx, runID, name)
|
||||
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"vetting/internal/api"
|
||||
"vetting/internal/db"
|
||||
"vetting/internal/events"
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/orchestrator"
|
||||
"vetting/internal/store"
|
||||
)
|
||||
|
||||
// setupAgentWithThresholds builds an Agent wired up to the thresholds
|
||||
// store + a Runner so the /sensor handler can drive the state machine.
|
||||
// Seeds one critical thermal threshold and parks the run in CPUStress
|
||||
// so the handler will stamp a stage-relevant failed_stage.
|
||||
func setupAgentWithThresholds(t *testing.T) (*api.Agent, int64, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "vetting.db")
|
||||
conn, err := db.Open(path)
|
||||
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}
|
||||
meas := &store.Measurements{DB: conn}
|
||||
thresholds := &store.Thresholds{DB: conn}
|
||||
hub := events.NewHub()
|
||||
runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub}
|
||||
|
||||
hostID, err := hosts.Create(context.Background(), model.Host{
|
||||
Name: "thresh-host",
|
||||
MAC: "aa:bb:cc:dd:ee:aa",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
}
|
||||
plain, hash, err := orchestrator.IssueRunToken()
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
runID, err := runs.Create(context.Background(), hostID, hash, false)
|
||||
if err != nil {
|
||||
t.Fatalf("create run: %v", err)
|
||||
}
|
||||
if err := stages.Seed(context.Background(), runID); err != nil {
|
||||
t.Fatalf("seed stages: %v", err)
|
||||
}
|
||||
// Park the run where a real thermal sidecar would be posting samples.
|
||||
if err := runs.SetState(context.Background(), runID, model.StateCPUStress); err != nil {
|
||||
t.Fatalf("set state: %v", err)
|
||||
}
|
||||
// Seed one critical thermal threshold.
|
||||
if _, err := thresholds.SeedForRun(context.Background(), runID, []store.ThresholdSpec{
|
||||
{Stage: "*", Kind: "temp", Key: "cpu/*", Op: "lt", Value: 92, Unit: "C", Severity: "critical", Source: "profile"},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed thresholds: %v", err)
|
||||
}
|
||||
return &api.Agent{
|
||||
Hosts: hosts,
|
||||
Runs: runs,
|
||||
Stages: stages,
|
||||
Measurements: meas,
|
||||
Thresholds: thresholds,
|
||||
Runner: runner,
|
||||
}, runID, plain
|
||||
}
|
||||
|
||||
// TestSensor_ThermalRunawayFailsRun: a sample that breaches a critical
|
||||
// threshold lands in threshold_evaluations (passed=0) and flips the
|
||||
// run into FailedHolding with failed_stage naming the current stage.
|
||||
// This is the Phase-1 behavior gate — without the evaluator, the sample
|
||||
// would just sit in measurements and the run would happily march on.
|
||||
func TestSensor_ThermalRunawayFailsRun(t *testing.T) {
|
||||
a, runID, token := setupAgentWithThresholds(t)
|
||||
batch := api.SensorBatch{Samples: []api.SensorSample{
|
||||
{Kind: "temp", Key: "cpu/0", Value: 95.3, Unit: "C"},
|
||||
}}
|
||||
buf, _ := json.Marshal(batch)
|
||||
req := routedRequest(runID, http.MethodPost,
|
||||
"/api/v1/runs/"+strconv.FormatInt(runID, 10)+"/sensor", buf)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.Sensor(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Breach bool `json:"breach"`
|
||||
Kind string `json:"breach_kind"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.Breach {
|
||||
t.Fatalf("expected breach=true, got %+v", resp)
|
||||
}
|
||||
run, err := a.Runs.Get(context.Background(), runID)
|
||||
if err != nil {
|
||||
t.Fatalf("get run: %v", err)
|
||||
}
|
||||
if run.State != model.StateFailedHolding {
|
||||
t.Fatalf("state = %s, want FailedHolding", run.State)
|
||||
}
|
||||
if run.FailedStage == "" {
|
||||
t.Fatalf("failed_stage empty; want stage-named breach")
|
||||
}
|
||||
evals, err := a.Thresholds.ListEvaluations(context.Background(), runID)
|
||||
if err != nil {
|
||||
t.Fatalf("list evaluations: %v", err)
|
||||
}
|
||||
if len(evals) != 1 {
|
||||
t.Fatalf("want 1 evaluation recorded, got %d", len(evals))
|
||||
}
|
||||
if evals[0].Passed {
|
||||
t.Fatalf("evaluation recorded as passed for 95.3C sample against <92C rule")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSensor_WithinThresholdPasses: a sample comfortably inside the
|
||||
// threshold writes an evaluation row with passed=1 and leaves the run
|
||||
// state untouched.
|
||||
func TestSensor_WithinThresholdPasses(t *testing.T) {
|
||||
a, runID, token := setupAgentWithThresholds(t)
|
||||
batch := api.SensorBatch{Samples: []api.SensorSample{
|
||||
{Kind: "temp", Key: "cpu/0", Value: 55.0, Unit: "C"},
|
||||
}}
|
||||
buf, _ := json.Marshal(batch)
|
||||
req := routedRequest(runID, http.MethodPost,
|
||||
"/api/v1/runs/"+strconv.FormatInt(runID, 10)+"/sensor", buf)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.Sensor(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
run, err := a.Runs.Get(context.Background(), runID)
|
||||
if err != nil {
|
||||
t.Fatalf("get run: %v", err)
|
||||
}
|
||||
if run.State != model.StateCPUStress {
|
||||
t.Fatalf("state = %s, want CPUStress unchanged", run.State)
|
||||
}
|
||||
evals, err := a.Thresholds.ListEvaluations(context.Background(), runID)
|
||||
if err != nil {
|
||||
t.Fatalf("list evaluations: %v", err)
|
||||
}
|
||||
if len(evals) != 1 || !evals[0].Passed {
|
||||
t.Fatalf("want 1 passed evaluation, got %+v", evals)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ func newCaptureRegistry(c *captureNotifier) *notify.Registry {
|
||||
// (agent, runID, plainTokenForBearer). Caller is responsible for
|
||||
// transitioning the run out of Queued.
|
||||
func fullAgent(t *testing.T) (*api.Agent, int64, string) {
|
||||
return fullAgentWithSpec(t, "")
|
||||
}
|
||||
|
||||
// fullAgentWithSpec is the same as fullAgent but seeds the host with
|
||||
// an ExpectedSpecYAML so SpecValidate can pick up diffs in the test.
|
||||
func fullAgentWithSpec(t *testing.T, expectedSpecYAML string) (*api.Agent, int64, string) {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(tmp, "vetting.db"))
|
||||
@@ -89,6 +95,7 @@ func fullAgent(t *testing.T) (*api.Agent, int64, string) {
|
||||
artifactStore := &store.Artifacts{DB: conn}
|
||||
specDiffStore := &store.SpecDiffs{DB: conn}
|
||||
measurementStore := &store.Measurements{DB: conn}
|
||||
firmwareStore := &store.Firmware{DB: conn}
|
||||
|
||||
hub := events.NewHub()
|
||||
logHub, err := logs.NewHub(filepath.Join(tmp, "logs"), hub)
|
||||
@@ -109,7 +116,7 @@ func fullAgent(t *testing.T) (*api.Agent, int64, string) {
|
||||
MAC: "aa:bb:cc:dd:ee:10",
|
||||
WoLBroadcastIP: "10.0.0.255",
|
||||
WoLPort: 9,
|
||||
ExpectedSpecYAML: "", // empty spec → no diffs
|
||||
ExpectedSpecYAML: expectedSpecYAML,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create host: %v", err)
|
||||
@@ -132,6 +139,7 @@ func fullAgent(t *testing.T) (*api.Agent, int64, string) {
|
||||
Artifacts: artifactStore,
|
||||
SpecDiffs: specDiffStore,
|
||||
Measurements: measurementStore,
|
||||
Firmware: firmwareStore,
|
||||
Runner: runner,
|
||||
EventHub: hub,
|
||||
Logs: logHub,
|
||||
@@ -195,20 +203,24 @@ func TestFullPipelineToCompleted(t *testing.T) {
|
||||
Memory: spec.MemorySpec{TotalGiB: 16},
|
||||
}
|
||||
next := walkStage(t, a, runID, token, "Inventory", true, map[string]any{"inventory": inv})
|
||||
// After Inventory → SpecValidate resolves inline → SMART
|
||||
if next != "SMART" {
|
||||
t.Fatalf("after Inventory, next_state = %q, want SMART", next)
|
||||
// After Inventory → Firmware
|
||||
if next != "Firmware" {
|
||||
t.Fatalf("after Inventory, next_state = %q, want Firmware", next)
|
||||
}
|
||||
|
||||
// The remaining stages advance one-for-one in order.
|
||||
// The remaining stages advance one-for-one in order. After Firmware
|
||||
// the inline SpecValidate resolver advances through SpecValidate to
|
||||
// SMART without a dedicated /result POST for SpecValidate.
|
||||
walkPlan := []struct {
|
||||
stage string
|
||||
expected string
|
||||
}{
|
||||
{"Firmware", "SMART"},
|
||||
{"SMART", "CPUStress"},
|
||||
{"CPUStress", "Storage"},
|
||||
{"Storage", "Network"},
|
||||
{"Network", "GPU"},
|
||||
{"Network", "Burn"},
|
||||
{"Burn", "GPU"},
|
||||
{"GPU", "PSU"},
|
||||
{"PSU", "Completed"}, // PSU → Reporting resolves inline → Completed
|
||||
}
|
||||
@@ -287,8 +299,11 @@ func TestFaultInjectionSMART(t *testing.T) {
|
||||
}
|
||||
|
||||
inv := spec.Inventory{Memory: spec.MemorySpec{TotalGiB: 16}}
|
||||
if next := walkStage(t, a, runID, token, "Inventory", true, map[string]any{"inventory": inv}); next != "SMART" {
|
||||
t.Fatalf("after Inventory, next = %q want SMART", next)
|
||||
if next := walkStage(t, a, runID, token, "Inventory", true, map[string]any{"inventory": inv}); next != "Firmware" {
|
||||
t.Fatalf("after Inventory, next = %q want Firmware", next)
|
||||
}
|
||||
if next := walkStage(t, a, runID, token, "Firmware", true, nil); next != "SMART" {
|
||||
t.Fatalf("after Firmware, next = %q want SMART (inline SpecValidate)", next)
|
||||
}
|
||||
|
||||
// Fake SMART failure → expect FailedHolding.
|
||||
@@ -316,3 +331,76 @@ func TestFaultInjectionSMART(t *testing.T) {
|
||||
t.Errorf("StageFailed severity = %q, want critical", ev.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirmwarePersistAndSpecMismatch exercises the Phase 4 firmware
|
||||
// integration: the agent POSTs Firmware snapshots; server persists; the
|
||||
// following SpecValidate diff picks up a firmware mismatch and parks
|
||||
// the run in FailedHolding with FailedStage=SpecValidate.
|
||||
func TestFirmwarePersistAndSpecMismatch(t *testing.T) {
|
||||
// Host demands BIOS 3.3; agent will POST 3.2 → one critical firmware diff.
|
||||
yaml := "firmware:\n - component: bios\n version: \"3.3\"\n"
|
||||
a, runID, token := fullAgentWithSpec(t, yaml)
|
||||
a.Notify = newCaptureRegistry(&captureNotifier{name: "capture"})
|
||||
|
||||
if err := a.Runs.SetState(context.Background(), runID, model.StateInventoryCheck); err != nil {
|
||||
t.Fatalf("set state: %v", err)
|
||||
}
|
||||
|
||||
inv := spec.Inventory{Memory: spec.MemorySpec{TotalGiB: 16}}
|
||||
if next := walkStage(t, a, runID, token, "Inventory", true, map[string]any{"inventory": inv}); next != "Firmware" {
|
||||
t.Fatalf("after Inventory, next = %q want Firmware", next)
|
||||
}
|
||||
|
||||
// Firmware stage: agent reports actual BIOS 3.2 → one row persisted.
|
||||
fw := []map[string]any{
|
||||
{"component": "bios", "identifier": "system", "version": "3.2", "vendor": "AMI"},
|
||||
}
|
||||
next := walkStage(t, a, runID, token, "Firmware", true, map[string]any{"firmware": fw})
|
||||
// Inline SpecValidate should detect the firmware mismatch and send
|
||||
// the run to FailedHolding without the agent posting SpecValidate.
|
||||
if next != "FailedHolding" {
|
||||
t.Fatalf("after Firmware mismatch, next = %q want FailedHolding", next)
|
||||
}
|
||||
|
||||
run, err := a.Runs.Get(context.Background(), runID)
|
||||
if err != nil {
|
||||
t.Fatalf("get run: %v", err)
|
||||
}
|
||||
if run.State != model.StateFailedHolding {
|
||||
t.Fatalf("run.State = %q, want FailedHolding", run.State)
|
||||
}
|
||||
if run.FailedStage != "SpecValidate" {
|
||||
t.Fatalf("run.FailedStage = %q, want SpecValidate", run.FailedStage)
|
||||
}
|
||||
|
||||
// Persistence: row landed in firmware_snapshots.
|
||||
snaps, err := a.Firmware.ListForRun(context.Background(), runID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForRun firmware: %v", err)
|
||||
}
|
||||
if len(snaps) != 1 {
|
||||
t.Fatalf("firmware rows = %d, want 1: %+v", len(snaps), snaps)
|
||||
}
|
||||
if snaps[0].Component != "bios" || snaps[0].Version != "3.2" {
|
||||
t.Errorf("persisted snapshot = %+v", snaps[0])
|
||||
}
|
||||
|
||||
// Diff row: SpecDiffs has a firmware-specific entry (rather than
|
||||
// only CPU/memory/disk rows) and is critical.
|
||||
diffs, err := a.SpecDiffs.ListForRun(context.Background(), runID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForRun specdiffs: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, d := range diffs {
|
||||
if strings.HasPrefix(d.Field, "firmware[") {
|
||||
found = true
|
||||
if d.Severity != "critical" {
|
||||
t.Errorf("firmware diff severity = %q, want critical", d.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no firmware[...] entry in spec diffs: %+v", diffs)
|
||||
}
|
||||
}
|
||||
|
||||
+64
-13
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"vetting/internal/config"
|
||||
"vetting/internal/events"
|
||||
"vetting/internal/logs"
|
||||
"vetting/internal/model"
|
||||
@@ -26,17 +27,19 @@ import (
|
||||
)
|
||||
|
||||
type UI struct {
|
||||
Hosts *store.Hosts
|
||||
Runs *store.Runs
|
||||
Stages *store.Stages
|
||||
SubSteps *store.SubSteps
|
||||
SpecDiffs *store.SpecDiffs
|
||||
Artifacts *store.Artifacts
|
||||
EventHub *events.Hub
|
||||
Logs *logs.Hub
|
||||
Runner *orchestrator.Runner
|
||||
Tiles *TileEnricher
|
||||
PublicURL string // user-visible base URL baked into the quick-register one-liner
|
||||
Hosts *store.Hosts
|
||||
Runs *store.Runs
|
||||
Stages *store.Stages
|
||||
SubSteps *store.SubSteps
|
||||
SpecDiffs *store.SpecDiffs
|
||||
Artifacts *store.Artifacts
|
||||
Thresholds *store.Thresholds // Phase 1: seeded at StartRun from Profiles
|
||||
Profiles *config.ProfileRegistry
|
||||
EventHub *events.Hub
|
||||
Logs *logs.Hub
|
||||
Runner *orchestrator.Runner
|
||||
Tiles *TileEnricher
|
||||
PublicURL string // user-visible base URL baked into the quick-register one-liner
|
||||
// PXE, when non-nil, gets Reload()ed after host create/delete so
|
||||
// dnsmasq's dhcp-host= allowlist reflects the current registry.
|
||||
// Without this, a newly-registered host PXE-boots and gets
|
||||
@@ -316,23 +319,71 @@ func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
nonDestructive := r.PostFormValue("non_destructive") == "1"
|
||||
profile := strings.TrimSpace(r.PostFormValue("profile"))
|
||||
if profile == "" {
|
||||
profile = config.ProfileQuick
|
||||
}
|
||||
if !config.IsValidProfile(profile) {
|
||||
http.Error(w, "unknown profile: "+profile, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, hash, err := orchestrator.IssueRunToken()
|
||||
if err != nil {
|
||||
http.Error(w, "token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
runID, err := u.Runs.Create(r.Context(), hostID, hash, nonDestructive)
|
||||
runID, err := u.Runs.CreateWithProfile(r.Context(), hostID, hash, nonDestructive, profile)
|
||||
if err != nil {
|
||||
http.Error(w, "create run: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("ui: created run %d for host %d (state=Queued)", runID, hostID)
|
||||
if err := u.seedThresholds(r.Context(), runID, host, profile); err != nil {
|
||||
// A threshold-seed failure shouldn't orphan a run row — log
|
||||
// and continue. Samples will just accumulate without a gate
|
||||
// until the operator retries, same as before Phase 1.
|
||||
log.Printf("ui: seed thresholds run %d: %v", runID, err)
|
||||
}
|
||||
log.Printf("ui: created run %d for host %d profile=%s (state=Queued)", runID, hostID, profile)
|
||||
// Send the operator straight to the new run — the button they clicked
|
||||
// was "Start vetting", the thing they want next is to watch it.
|
||||
http.Redirect(w, r, fmt.Sprintf("/runs/%d", runID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// seedThresholds materializes the per-run threshold table from the
|
||||
// ProfileRegistry. The shared vetting.thresholds block applies to
|
||||
// every profile; future per-profile overrides will layer on top here,
|
||||
// and per-host overrides (Phase 1 extra) land via ExpectedSpecYAML in
|
||||
// a later iteration. Safe to skip silently when Thresholds or the
|
||||
// registry isn't wired — tests do not always build one.
|
||||
func (u *UI) seedThresholds(ctx context.Context, runID int64, host *model.Host, profile string) error {
|
||||
if u.Thresholds == nil || u.Profiles == nil {
|
||||
return nil
|
||||
}
|
||||
_ = host // reserved for per-host override layer
|
||||
_ = profile // reserved for per-profile override layer
|
||||
defaults := u.Profiles.Vetting.Thresholds
|
||||
if len(defaults) == 0 {
|
||||
return nil
|
||||
}
|
||||
specs := make([]store.ThresholdSpec, 0, len(defaults))
|
||||
for _, d := range defaults {
|
||||
specs = append(specs, store.ThresholdSpec{
|
||||
Stage: d.Stage,
|
||||
Kind: d.Kind,
|
||||
Key: d.Key,
|
||||
Op: d.Op,
|
||||
Value: d.Value,
|
||||
Nominal: d.Nominal,
|
||||
Unit: d.Unit,
|
||||
Severity: d.Severity,
|
||||
Source: "profile",
|
||||
})
|
||||
}
|
||||
_, err := u.Thresholds.SeedForRun(ctx, runID, specs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
|
||||
_ = templates.Registration(templates.RegistrationForm{
|
||||
QuickRegisterURL: u.baseURL(r),
|
||||
|
||||
@@ -20,6 +20,13 @@ type Config struct {
|
||||
Agent Agent `yaml:"agent"`
|
||||
Notifiers []Notifier `yaml:"notifiers"`
|
||||
Routes []Route `yaml:"routes"`
|
||||
|
||||
// Profiles holds the Phase-1 quick/deep/soak registry (stage order,
|
||||
// threshold defaults, per-profile stage timeouts + probe knobs).
|
||||
// Populated from the `vetting:` and `profiles:` top-level blocks
|
||||
// during Load. Nil is never returned — Load installs a default
|
||||
// registry when those blocks are absent.
|
||||
Profiles *ProfileRegistry `yaml:"-"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -111,6 +118,20 @@ func Load(path string) (*Config, error) {
|
||||
if err := yaml.Unmarshal(b, &c); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
// The `vetting:` + `profiles:` blocks live alongside the existing
|
||||
// fields but we decode them into the raw shape because YAML
|
||||
// durations arrive as strings. Reusing the same byte buffer is
|
||||
// safe: yaml.Unmarshal is happy to ignore keys the target doesn't
|
||||
// know about.
|
||||
var rawProfiles rawProfilesBlock
|
||||
if err := yaml.Unmarshal(b, &rawProfiles); err != nil {
|
||||
return nil, fmt.Errorf("parse profiles: %w", err)
|
||||
}
|
||||
reg, err := buildProfileRegistry(rawProfiles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("profiles: %w", err)
|
||||
}
|
||||
c.Profiles = reg
|
||||
if c.Server.Bind == "" {
|
||||
c.Server.Bind = "127.0.0.1:8080"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProfileName is the set of legal values for a Run's profile column.
|
||||
// Exposed as constants so callers (UI handler, tests, agent) don't
|
||||
// sprinkle literal strings.
|
||||
const (
|
||||
ProfileQuick = "quick"
|
||||
ProfileDeep = "deep"
|
||||
ProfileSoak = "soak"
|
||||
)
|
||||
|
||||
// AllProfiles is the canonical ordering shown in the picker. Leftmost
|
||||
// is the default; rightmost is the longest-running.
|
||||
var AllProfiles = []string{ProfileQuick, ProfileDeep, ProfileSoak}
|
||||
|
||||
// IsValidProfile returns true when name is one of the known profile
|
||||
// identifiers. Used at the UI boundary to reject malformed POSTs and in
|
||||
// store code as a fallback guard.
|
||||
func IsValidProfile(name string) bool {
|
||||
for _, p := range AllProfiles {
|
||||
if p == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Vetting holds the stage order + threshold defaults that are shared
|
||||
// across all profiles. Only the per-stage durations/concurrency differ
|
||||
// between quick/deep/soak; gates like "CPU > 92C fails the run" apply
|
||||
// to a 2-minute quick run and a 12-hour soak alike.
|
||||
type Vetting struct {
|
||||
Stages []string `yaml:"stages"`
|
||||
Thresholds []ThresholdDefaults `yaml:"thresholds"`
|
||||
}
|
||||
|
||||
// ThresholdDefaults is the YAML shape of a threshold declaration. One
|
||||
// stanza can declare a per-stage rule ("stage: Network") or a global
|
||||
// rule ("stage: *") — the threshold evaluator applies both to samples
|
||||
// with matching (stage, kind, key).
|
||||
type ThresholdDefaults struct {
|
||||
Stage string `yaml:"stage"`
|
||||
Kind string `yaml:"kind"`
|
||||
Key string `yaml:"key"`
|
||||
Op string `yaml:"op"` // lt|lte|gt|gte|within_pct
|
||||
Value float64 `yaml:"value"`
|
||||
Nominal float64 `yaml:"nominal"` // only used by within_pct (e.g. 12.0 for +12V rail)
|
||||
Unit string `yaml:"unit"`
|
||||
Severity string `yaml:"severity"` // critical|warning
|
||||
}
|
||||
|
||||
// ProfileRegistry is the in-memory view of the `profiles:` block in
|
||||
// vetting.yaml. The orchestrator queries it at run creation time to
|
||||
// seed thresholds and (in Phase 3+) to scale per-stage durations.
|
||||
type ProfileRegistry struct {
|
||||
// Shared stage ordering + threshold defaults. Every profile walks
|
||||
// the same list; only durations/concurrency differ.
|
||||
Vetting Vetting
|
||||
|
||||
// Profiles is keyed by name ("quick"/"deep"/"soak"). Inherit is
|
||||
// already resolved at load time — a caller sees a flattened view.
|
||||
Profiles map[string]Profile
|
||||
}
|
||||
|
||||
// Profile is a loaded profile. StageTimeouts is keyed by stage name.
|
||||
// Defaults carries the free-form knobs each probe reads.
|
||||
type Profile struct {
|
||||
Name string
|
||||
Inherit string
|
||||
StageTimeouts map[string]time.Duration
|
||||
Defaults map[string]map[string]any
|
||||
}
|
||||
|
||||
// StageConfig is the flat view of a profile's knobs, shipped on the
|
||||
// claim response so the agent can size CPUStress/Storage/Network/Burn
|
||||
// work without parsing YAML. Empty values mean "fall back to the
|
||||
// agent's compile-time default" — an older orchestrator that doesn't
|
||||
// set these fields keeps working unchanged.
|
||||
type StageConfig struct {
|
||||
Profile string `json:"profile"`
|
||||
StageTimeouts map[string]string `json:"stage_timeouts,omitempty"`
|
||||
CPUStress CPUStressKnobs `json:"cpustress"`
|
||||
Storage StorageKnobs `json:"storage"`
|
||||
Network NetworkKnobs `json:"network"`
|
||||
Burn BurnKnobs `json:"burn"`
|
||||
}
|
||||
|
||||
// CPUStressKnobs parallels the `cpustress:` block under `profiles.<name>.defaults`.
|
||||
// Durations are YAML duration strings ("2m", "60m", "12h").
|
||||
type CPUStressKnobs struct {
|
||||
CPUPass string `json:"cpu_pass,omitempty"`
|
||||
MemPass string `json:"mem_pass,omitempty"`
|
||||
EDACPoll string `json:"edac_poll,omitempty"`
|
||||
}
|
||||
|
||||
// StorageKnobs parallels `storage:` defaults. Mode is "fio_sample" (quick)
|
||||
// or "full_disk" (deep/soak). Verify names the integrity mode ("md5" or "").
|
||||
type StorageKnobs struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
FioSize string `json:"fio_size,omitempty"`
|
||||
FioTime string `json:"fio_time,omitempty"`
|
||||
FioBS string `json:"fio_bs,omitempty"`
|
||||
FioRW string `json:"fio_rw,omitempty"`
|
||||
Verify string `json:"verify,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkKnobs parallels `network:` defaults. Duration is a YAML string.
|
||||
type NetworkKnobs struct {
|
||||
Duration string `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// BurnKnobs parallels `burn:` defaults. Duration is the total Burn window.
|
||||
// CPUWorkers is "all" (agent picks runtime.NumCPU) or a numeric string.
|
||||
// MemPct is a percentage of MemAvailable to stress. FioOnSpare gates
|
||||
// whether fio runs inside Burn (set false if operator lacks a spare
|
||||
// partition). IperfParallel is the parallel stream count fed to iperf3 -P.
|
||||
type BurnKnobs struct {
|
||||
Duration string `json:"duration,omitempty"`
|
||||
CPUWorkers string `json:"cpu_workers,omitempty"`
|
||||
MemPct int `json:"mem_pct,omitempty"`
|
||||
FioOnSpare bool `json:"fio_on_spare,omitempty"`
|
||||
IperfParallel int `json:"iperf_parallel,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveStageConfig flattens the named profile into the wire shape the
|
||||
// claim handler ships. Missing keys render as empty strings so the agent
|
||||
// falls back to its own defaults.
|
||||
func (pr *ProfileRegistry) ResolveStageConfig(name string) StageConfig {
|
||||
if pr == nil {
|
||||
return StageConfig{Profile: name}
|
||||
}
|
||||
p, err := pr.Lookup(name)
|
||||
if err != nil {
|
||||
return StageConfig{Profile: name}
|
||||
}
|
||||
out := StageConfig{Profile: p.Name}
|
||||
if len(p.StageTimeouts) > 0 {
|
||||
out.StageTimeouts = make(map[string]string, len(p.StageTimeouts))
|
||||
for k, v := range p.StageTimeouts {
|
||||
out.StageTimeouts[k] = v.String()
|
||||
}
|
||||
}
|
||||
cpu := p.Defaults["cpustress"]
|
||||
out.CPUStress.CPUPass = yamlString(cpu, "cpu_pass")
|
||||
out.CPUStress.MemPass = yamlString(cpu, "mem_pass")
|
||||
out.CPUStress.EDACPoll = yamlString(cpu, "edac_poll")
|
||||
st := p.Defaults["storage"]
|
||||
out.Storage.Mode = yamlString(st, "mode")
|
||||
out.Storage.FioSize = yamlString(st, "fio_size")
|
||||
out.Storage.FioTime = yamlString(st, "fio_time")
|
||||
out.Storage.FioBS = yamlString(st, "fio_bs")
|
||||
out.Storage.FioRW = yamlString(st, "fio_rw")
|
||||
out.Storage.Verify = yamlString(st, "verify")
|
||||
net := p.Defaults["network"]
|
||||
out.Network.Duration = yamlString(net, "duration")
|
||||
burn := p.Defaults["burn"]
|
||||
out.Burn.Duration = yamlString(burn, "duration")
|
||||
out.Burn.CPUWorkers = yamlString(burn, "cpu_workers")
|
||||
out.Burn.MemPct = yamlInt(burn, "mem_pct")
|
||||
out.Burn.FioOnSpare = yamlBool(burn, "fio_on_spare")
|
||||
out.Burn.IperfParallel = yamlInt(burn, "iperf_parallel")
|
||||
return out
|
||||
}
|
||||
|
||||
// yamlInt coerces a map[string]any entry to int. Accepts native int,
|
||||
// float64 (JSON numbers round-trip as float), or numeric string. Missing
|
||||
// / malformed values return 0 so the agent falls back to its default.
|
||||
func yamlInt(m map[string]any, key string) int {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return 0
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case string:
|
||||
// Best-effort string → int. Empty and non-numeric fall through
|
||||
// to zero.
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(x, "%d", &n); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// yamlBool accepts native bool or "true"/"false" strings. Anything else
|
||||
// (missing key, numeric, typo) returns false — a safer default than
|
||||
// "true" for a destructive knob like fio_on_spare.
|
||||
func yamlBool(m map[string]any, key string) bool {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return strings.EqualFold(x, "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// yamlString coerces a map[string]any entry to its string form. YAML
|
||||
// durations like "2m" parse as strings; numeric literals like 5 parse as
|
||||
// int. We format non-string scalars with fmt.Sprint so the agent can
|
||||
// still interpret them.
|
||||
func yamlString(m map[string]any, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
// Lookup returns the profile with the given name. Falls back to the
|
||||
// default profile (quick) if the name is empty. Returns an error when
|
||||
// the name is non-empty but unknown so the caller can surface it.
|
||||
func (pr *ProfileRegistry) Lookup(name string) (Profile, error) {
|
||||
if name == "" {
|
||||
name = ProfileQuick
|
||||
}
|
||||
p, ok := pr.Profiles[name]
|
||||
if !ok {
|
||||
return Profile{}, fmt.Errorf("unknown profile %q", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Names returns the registry's profile names in the canonical
|
||||
// picker order (quick/deep/soak). Profiles present in the config but
|
||||
// unknown to AllProfiles are appended after, alphabetically.
|
||||
func (pr *ProfileRegistry) Names() []string {
|
||||
out := make([]string, 0, len(pr.Profiles))
|
||||
seen := map[string]bool{}
|
||||
for _, n := range AllProfiles {
|
||||
if _, ok := pr.Profiles[n]; ok {
|
||||
out = append(out, n)
|
||||
seen[n] = true
|
||||
}
|
||||
}
|
||||
for n := range pr.Profiles {
|
||||
if !seen[n] {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Stages returns the shared stage order, or a safe default when the
|
||||
// config didn't declare one — keeps tests that don't build a full
|
||||
// ProfileRegistry from tripping over a nil slice.
|
||||
func (pr *ProfileRegistry) Stages() []string {
|
||||
if len(pr.Vetting.Stages) == 0 {
|
||||
return DefaultStages()
|
||||
}
|
||||
out := make([]string, len(pr.Vetting.Stages))
|
||||
copy(out, pr.Vetting.Stages)
|
||||
return out
|
||||
}
|
||||
|
||||
// DefaultStages is the canonical stage list the orchestrator walks
|
||||
// when no config is loaded. Mirrored in the vetting.yaml shipped with
|
||||
// the repo so edits to the slice and the file stay in sync.
|
||||
func DefaultStages() []string {
|
||||
return []string{
|
||||
"Inventory",
|
||||
"Firmware",
|
||||
"SpecValidate",
|
||||
"SMART",
|
||||
"CPUStress",
|
||||
"Storage",
|
||||
"Network",
|
||||
"Burn",
|
||||
"GPU",
|
||||
"PSU",
|
||||
"Reporting",
|
||||
}
|
||||
}
|
||||
|
||||
// rawProfile is the YAML shape before inherit resolution. Durations
|
||||
// arrive as strings (e.g. "2h") so we can parse them with
|
||||
// time.ParseDuration instead of rolling our own.
|
||||
type rawProfile struct {
|
||||
Inherit string `yaml:"inherit"`
|
||||
StageTimeouts map[string]string `yaml:"stage_timeouts"`
|
||||
Defaults map[string]map[string]any `yaml:"defaults"`
|
||||
}
|
||||
|
||||
type rawProfilesBlock struct {
|
||||
Vetting Vetting `yaml:"vetting"`
|
||||
Profiles map[string]rawProfile `yaml:"profiles"`
|
||||
}
|
||||
|
||||
// buildProfileRegistry flattens a rawProfilesBlock into a ProfileRegistry.
|
||||
// Resolves `inherit:` by recursive merge (child keys win), parses
|
||||
// stage_timeouts strings into time.Durations, and returns an error if
|
||||
// the inherit chain loops or references an unknown profile.
|
||||
func buildProfileRegistry(raw rawProfilesBlock) (*ProfileRegistry, error) {
|
||||
if len(raw.Profiles) == 0 {
|
||||
raw.Profiles = defaultRawProfiles()
|
||||
}
|
||||
out := &ProfileRegistry{
|
||||
Vetting: raw.Vetting,
|
||||
Profiles: make(map[string]Profile, len(raw.Profiles)),
|
||||
}
|
||||
if len(out.Vetting.Stages) == 0 {
|
||||
out.Vetting.Stages = DefaultStages()
|
||||
}
|
||||
for name := range raw.Profiles {
|
||||
resolved, err := resolveProfile(raw.Profiles, name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Profiles[name] = resolved
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveProfile recursively walks inherit chains, depth-first. The
|
||||
// visited slice is a cycle guard — we add the current name before
|
||||
// recursing and bail if we ever see it again.
|
||||
func resolveProfile(all map[string]rawProfile, name string, visited []string) (Profile, error) {
|
||||
for _, v := range visited {
|
||||
if v == name {
|
||||
return Profile{}, fmt.Errorf("profile inherit cycle: %s -> %s", strings.Join(visited, " -> "), name)
|
||||
}
|
||||
}
|
||||
raw, ok := all[name]
|
||||
if !ok {
|
||||
return Profile{}, fmt.Errorf("unknown profile %q", name)
|
||||
}
|
||||
base := Profile{
|
||||
Name: name,
|
||||
Inherit: raw.Inherit,
|
||||
StageTimeouts: map[string]time.Duration{},
|
||||
Defaults: map[string]map[string]any{},
|
||||
}
|
||||
if raw.Inherit != "" {
|
||||
parent, err := resolveProfile(all, raw.Inherit, append(visited, name))
|
||||
if err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
for k, v := range parent.StageTimeouts {
|
||||
base.StageTimeouts[k] = v
|
||||
}
|
||||
for k, v := range parent.Defaults {
|
||||
copyMap := make(map[string]any, len(v))
|
||||
for kk, vv := range v {
|
||||
copyMap[kk] = vv
|
||||
}
|
||||
base.Defaults[k] = copyMap
|
||||
}
|
||||
}
|
||||
for stage, s := range raw.StageTimeouts {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return Profile{}, fmt.Errorf("profile %s stage_timeouts[%s]: %w", name, stage, err)
|
||||
}
|
||||
base.StageTimeouts[stage] = d
|
||||
}
|
||||
for group, kv := range raw.Defaults {
|
||||
dest, ok := base.Defaults[group]
|
||||
if !ok {
|
||||
dest = map[string]any{}
|
||||
base.Defaults[group] = dest
|
||||
}
|
||||
for k, v := range kv {
|
||||
dest[k] = v
|
||||
}
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// defaultRawProfiles returns sane per-profile durations + probe knobs
|
||||
// used when vetting.yaml omits the `profiles:` block entirely. Matches
|
||||
// the plan's per-stage budget table so the agent still gets coherent
|
||||
// CPUStress/Storage/Network knobs without any operator-visible config.
|
||||
func defaultRawProfiles() map[string]rawProfile {
|
||||
return map[string]rawProfile{
|
||||
ProfileQuick: {
|
||||
StageTimeouts: map[string]string{
|
||||
"CPUStress": "5m",
|
||||
"Storage": "5m",
|
||||
"Network": "2m",
|
||||
"Burn": "3m",
|
||||
"PSU": "1m",
|
||||
},
|
||||
Defaults: map[string]map[string]any{
|
||||
"cpustress": {"cpu_pass": "2m", "mem_pass": "2m", "edac_poll": "10s"},
|
||||
"storage": {"mode": "fio_sample", "fio_size": "1GiB", "fio_time": "3m", "fio_bs": "4k", "fio_rw": "randrw", "verify": "md5"},
|
||||
"network": {"duration": "60s"},
|
||||
"burn": {"duration": "2m", "cpu_workers": "all", "mem_pct": 50, "fio_on_spare": true, "iperf_parallel": 2},
|
||||
},
|
||||
},
|
||||
ProfileDeep: {
|
||||
StageTimeouts: map[string]string{
|
||||
"CPUStress": "2h",
|
||||
"Storage": "4h",
|
||||
"Network": "35m",
|
||||
"Burn": "3h",
|
||||
"PSU": "10m",
|
||||
},
|
||||
Defaults: map[string]map[string]any{
|
||||
"cpustress": {"cpu_pass": "60m", "mem_pass": "60m", "edac_poll": "10s"},
|
||||
"storage": {"mode": "full_disk", "fio_time": "2h", "fio_bs": "4k", "fio_rw": "randrw", "verify": "md5"},
|
||||
"network": {"duration": "30m"},
|
||||
"burn": {"duration": "2h", "cpu_workers": "all", "mem_pct": 70, "fio_on_spare": true, "iperf_parallel": 4},
|
||||
},
|
||||
},
|
||||
ProfileSoak: {
|
||||
Inherit: ProfileDeep,
|
||||
StageTimeouts: map[string]string{
|
||||
"CPUStress": "14h",
|
||||
"Storage": "8h",
|
||||
"Network": "2h30m",
|
||||
"Burn": "20h",
|
||||
"PSU": "15m",
|
||||
},
|
||||
Defaults: map[string]map[string]any{
|
||||
"cpustress": {"cpu_pass": "12h"},
|
||||
"storage": {"mode": "full_disk", "fio_time": "6h"},
|
||||
"network": {"duration": "2h"},
|
||||
"burn": {"duration": "18h", "iperf_parallel": 8},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Phase-1 groundwork for profile-aware, threshold-gated vetting.
|
||||
--
|
||||
-- Adds:
|
||||
-- * runs.profile — which profile the run is executing
|
||||
-- (quick|deep|soak; defaults to quick for
|
||||
-- backfill of older rows + tests).
|
||||
-- * thresholds — seeded per run at creation from the
|
||||
-- ProfileRegistry + per-host overrides;
|
||||
-- immutable for that run so a late config
|
||||
-- edit can't retroactively pass/fail it.
|
||||
-- * threshold_evaluations — one row per observed sample vs threshold;
|
||||
-- drives the report + pipeline badges.
|
||||
-- * firmware_snapshots — per-run BIOS/BMC/NIC/HBA/microcode/NVMe
|
||||
-- version captures used by SpecValidate
|
||||
-- diffing in Phase 4.
|
||||
|
||||
ALTER TABLE runs ADD COLUMN profile TEXT NOT NULL DEFAULT 'quick';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thresholds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
||||
stage_name TEXT NOT NULL, -- "*" matches any stage
|
||||
kind TEXT NOT NULL, -- temp|psu_volt|iperf|fio_p99_us|nic_retrans|edac_ce|edac_ue|mce|...
|
||||
key TEXT NOT NULL, -- "*" or glob-ish match (prefix* / *suffix / exact)
|
||||
op TEXT NOT NULL, -- lt|lte|gt|gte|within_pct
|
||||
threshold REAL NOT NULL,
|
||||
nominal REAL NOT NULL DEFAULT 0, -- used by within_pct; 0 elsewhere
|
||||
unit TEXT NOT NULL DEFAULT '',
|
||||
severity TEXT NOT NULL, -- critical|warning
|
||||
source TEXT NOT NULL -- profile|host_override
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thresholds_run ON thresholds(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_thresholds_kind ON thresholds(run_id, stage_name, kind);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS threshold_evaluations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
||||
threshold_id INTEGER NOT NULL REFERENCES thresholds(id) ON DELETE CASCADE,
|
||||
stage_name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
ts TIMESTAMP NOT NULL,
|
||||
observed REAL NOT NULL,
|
||||
passed INTEGER NOT NULL -- 1 = sample within threshold, 0 = breach
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_threshold_evals_run ON threshold_evaluations(run_id, passed);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS firmware_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
||||
component TEXT NOT NULL, -- bios|bmc|nic|hba|microcode|nvme_fw
|
||||
identifier TEXT NOT NULL, -- slot/serial/device path that distinguishes this component
|
||||
version TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL DEFAULT '',
|
||||
raw_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_firmware_run ON firmware_snapshots(run_id, component);
|
||||
@@ -26,11 +26,13 @@ const (
|
||||
StateWaitingReboot RunState = "WaitingReboot"
|
||||
StateBooting RunState = "Booting"
|
||||
StateInventoryCheck RunState = "InventoryCheck"
|
||||
StateFirmware RunState = "Firmware"
|
||||
StateSpecValidate RunState = "SpecValidate"
|
||||
StateSMART RunState = "SMART"
|
||||
StateCPUStress RunState = "CPUStress"
|
||||
StateStorage RunState = "Storage"
|
||||
StateNetwork RunState = "Network"
|
||||
StateBurn RunState = "Burn"
|
||||
StateGPU RunState = "GPU"
|
||||
StatePSU RunState = "PSU"
|
||||
StateReporting RunState = "Reporting"
|
||||
@@ -63,6 +65,7 @@ type Run struct {
|
||||
HoldIP string
|
||||
OverrideFlagsJSON string
|
||||
NonDestructive bool
|
||||
Profile string // quick|deep|soak; empty is treated as "quick"
|
||||
}
|
||||
|
||||
type StageState string
|
||||
|
||||
@@ -119,9 +119,9 @@ func (d *Dispatcher) pickNext(ctx context.Context) {
|
||||
queued = &runs[i]
|
||||
}
|
||||
case model.StateWaitingWoL, model.StateWaitingReboot, model.StateBooting,
|
||||
model.StateInventoryCheck, model.StateSpecValidate, model.StateSMART,
|
||||
model.StateInventoryCheck, model.StateFirmware, model.StateSpecValidate, model.StateSMART,
|
||||
model.StateCPUStress, model.StateStorage, model.StateNetwork,
|
||||
model.StateGPU, model.StatePSU, model.StateReporting:
|
||||
model.StateBurn, model.StateGPU, model.StatePSU, model.StateReporting:
|
||||
inFlight++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,13 @@ const (
|
||||
// "InventoryCheck". Later stages share a name with their state.
|
||||
var stageStates = map[string]model.RunState{
|
||||
"Inventory": model.StateInventoryCheck,
|
||||
"Firmware": model.StateFirmware,
|
||||
"SpecValidate": model.StateSpecValidate,
|
||||
"SMART": model.StateSMART,
|
||||
"CPUStress": model.StateCPUStress,
|
||||
"Storage": model.StateStorage,
|
||||
"Network": model.StateNetwork,
|
||||
"Burn": model.StateBurn,
|
||||
"GPU": model.StateGPU,
|
||||
"PSU": model.StatePSU,
|
||||
"Reporting": model.StateReporting,
|
||||
@@ -44,11 +46,13 @@ var stageStates = map[string]model.RunState{
|
||||
// first stage to Completed. Kept in sync with store.DefaultStageOrder.
|
||||
var stageOrder = []model.RunState{
|
||||
model.StateInventoryCheck,
|
||||
model.StateFirmware,
|
||||
model.StateSpecValidate,
|
||||
model.StateSMART,
|
||||
model.StateCPUStress,
|
||||
model.StateStorage,
|
||||
model.StateNetwork,
|
||||
model.StateBurn,
|
||||
model.StateGPU,
|
||||
model.StatePSU,
|
||||
model.StateReporting,
|
||||
@@ -143,9 +147,9 @@ func nextStageState(current model.RunState) (model.RunState, error) {
|
||||
func allActiveStates() []model.RunState {
|
||||
return []model.RunState{
|
||||
model.StateQueued, model.StateWaitingWoL, model.StateWaitingReboot, model.StateBooting,
|
||||
model.StateInventoryCheck, model.StateSpecValidate, model.StateSMART,
|
||||
model.StateInventoryCheck, model.StateFirmware, model.StateSpecValidate, model.StateSMART,
|
||||
model.StateCPUStress, model.StateStorage, model.StateNetwork,
|
||||
model.StateGPU, model.StatePSU, model.StateReporting,
|
||||
model.StateBurn, model.StateGPU, model.StatePSU, model.StateReporting,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,11 +80,13 @@ func TestTriggerAgentClaimedFromWaitingReboot(t *testing.T) {
|
||||
func TestTriggerStageMismatch(t *testing.T) {
|
||||
stageStates := []model.RunState{
|
||||
model.StateInventoryCheck,
|
||||
model.StateFirmware,
|
||||
model.StateSpecValidate,
|
||||
model.StateSMART,
|
||||
model.StateCPUStress,
|
||||
model.StateStorage,
|
||||
model.StateNetwork,
|
||||
model.StateBurn,
|
||||
model.StateGPU,
|
||||
model.StatePSU,
|
||||
model.StateReporting,
|
||||
@@ -114,11 +116,13 @@ func TestTriggerStageMismatch(t *testing.T) {
|
||||
func TestStageNameForState(t *testing.T) {
|
||||
pairs := map[string]model.RunState{
|
||||
"Inventory": model.StateInventoryCheck,
|
||||
"Firmware": model.StateFirmware,
|
||||
"SpecValidate": model.StateSpecValidate,
|
||||
"SMART": model.StateSMART,
|
||||
"CPUStress": model.StateCPUStress,
|
||||
"Storage": model.StateStorage,
|
||||
"Network": model.StateNetwork,
|
||||
"Burn": model.StateBurn,
|
||||
"GPU": model.StateGPU,
|
||||
"PSU": model.StatePSU,
|
||||
"Reporting": model.StateReporting,
|
||||
@@ -143,11 +147,13 @@ func TestNextStageWalk(t *testing.T) {
|
||||
// one in the canonical order, and from Reporting onto Completed.
|
||||
chain := []model.RunState{
|
||||
model.StateInventoryCheck,
|
||||
model.StateFirmware,
|
||||
model.StateSpecValidate,
|
||||
model.StateSMART,
|
||||
model.StateCPUStress,
|
||||
model.StateStorage,
|
||||
model.StateNetwork,
|
||||
model.StateBurn,
|
||||
model.StateGPU,
|
||||
model.StatePSU,
|
||||
model.StateReporting,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ThresholdOp is one of the comparison operators a threshold supports.
|
||||
// within_pct is the only one that cares about a "nominal" value for
|
||||
// the key — used for PSU rails ("+12V within 5% of 12.0").
|
||||
type ThresholdOp string
|
||||
|
||||
const (
|
||||
OpLT ThresholdOp = "lt"
|
||||
OpLTE ThresholdOp = "lte"
|
||||
OpGT ThresholdOp = "gt"
|
||||
OpGTE ThresholdOp = "gte"
|
||||
OpWithinPct ThresholdOp = "within_pct"
|
||||
)
|
||||
|
||||
// ThresholdSeverity routes a breach to either "fail the run" or "just
|
||||
// surface a warning in the report". The evaluator returns it alongside
|
||||
// the Pass flag so the caller can decide whether to transition the run.
|
||||
type ThresholdSeverity string
|
||||
|
||||
const (
|
||||
SeverityCritical ThresholdSeverity = "critical"
|
||||
SeverityWarning ThresholdSeverity = "warning"
|
||||
)
|
||||
|
||||
// Threshold is the evaluator's view of a stored threshold row. It's a
|
||||
// flat, already-parsed value-object — the evaluator doesn't look at
|
||||
// the DB and the store doesn't look at the evaluator.
|
||||
type Threshold struct {
|
||||
ID int64
|
||||
Stage string // "*" matches any stage
|
||||
Kind string
|
||||
Key string // glob-ish: "*" / "prefix*" / "*suffix" / exact
|
||||
Op ThresholdOp
|
||||
Value float64
|
||||
Nominal float64 // for within_pct (nominal voltage/frequency)
|
||||
Severity ThresholdSeverity
|
||||
}
|
||||
|
||||
// Sample is a single observation the evaluator tests against matching
|
||||
// thresholds. Stage may be empty when the agent doesn't know which
|
||||
// stage posted it (e.g. the thermal sidecar running across stages) —
|
||||
// empty-stage samples only match thresholds with Stage == "*".
|
||||
type Sample struct {
|
||||
Stage string
|
||||
Kind string
|
||||
Key string
|
||||
Value float64
|
||||
}
|
||||
|
||||
// EvalResult is the per-sample outcome of a threshold evaluation:
|
||||
// which threshold was consulted, whether the sample passed, and the
|
||||
// severity so the caller can fast-fail on critical breaches.
|
||||
type EvalResult struct {
|
||||
Threshold Threshold
|
||||
Passed bool
|
||||
Observed float64
|
||||
}
|
||||
|
||||
// Breached returns true when the sample violated the threshold.
|
||||
func (r EvalResult) Breached() bool { return !r.Passed }
|
||||
|
||||
// CriticalBreach returns true only for critical-severity breaches —
|
||||
// the "fail the run right now" case.
|
||||
func (r EvalResult) CriticalBreach() bool {
|
||||
return r.Breached() && r.Threshold.Severity == SeverityCritical
|
||||
}
|
||||
|
||||
// Evaluate runs a single sample through every threshold that applies
|
||||
// to it. A sample may match more than one threshold (a generic "*"
|
||||
// rule + a stage-specific override); each match produces its own
|
||||
// EvalResult in the returned slice so both get persisted.
|
||||
func Evaluate(sample Sample, thresholds []Threshold) []EvalResult {
|
||||
out := make([]EvalResult, 0, 1)
|
||||
for _, t := range thresholds {
|
||||
if !thresholdMatchesSample(t, sample) {
|
||||
continue
|
||||
}
|
||||
passed, err := evaluateOp(t.Op, sample.Value, t.Value, t.Nominal)
|
||||
if err != nil {
|
||||
// Unknown operator — skip. The caller could validate on
|
||||
// insert; here we prefer to drop the threshold than to
|
||||
// return an error that forces every Sensor write to 500.
|
||||
continue
|
||||
}
|
||||
out = append(out, EvalResult{
|
||||
Threshold: t,
|
||||
Passed: passed,
|
||||
Observed: sample.Value,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// thresholdMatchesSample applies the stage + kind + key filter. Kind
|
||||
// is always literal — there's no "any kind" threshold and if there
|
||||
// ever is we'll add a `kind: *` escape hatch. Stage and key both
|
||||
// support glob-ish matching.
|
||||
func thresholdMatchesSample(t Threshold, s Sample) bool {
|
||||
if t.Kind != s.Kind {
|
||||
return false
|
||||
}
|
||||
if !stageMatches(t.Stage, s.Stage) {
|
||||
return false
|
||||
}
|
||||
if !keyMatches(t.Key, s.Key) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stageMatches returns true if the threshold's stage selector applies
|
||||
// to the sample's stage. "*" matches everything; empty threshold
|
||||
// selector is treated as "*" so a threshold declared without a stage
|
||||
// key isn't accidentally inert. A sample without a stage only matches
|
||||
// the "*" selector — we don't guess.
|
||||
func stageMatches(selector, sampleStage string) bool {
|
||||
if selector == "" || selector == "*" {
|
||||
return true
|
||||
}
|
||||
return selector == sampleStage
|
||||
}
|
||||
|
||||
// keyMatches handles "*", "prefix*", "*suffix", and exact match. We
|
||||
// avoid pulling in filepath.Match so Windows `\`-vs-`/` rules don't
|
||||
// leak into the sample namespace (key "eth0/rx_errors" is not a path).
|
||||
func keyMatches(pattern, key string) bool {
|
||||
if pattern == "" || pattern == "*" {
|
||||
return true
|
||||
}
|
||||
hasPrefix := strings.HasPrefix(pattern, "*")
|
||||
hasSuffix := strings.HasSuffix(pattern, "*")
|
||||
switch {
|
||||
case hasPrefix && hasSuffix:
|
||||
inner := strings.TrimPrefix(strings.TrimSuffix(pattern, "*"), "*")
|
||||
return strings.Contains(key, inner)
|
||||
case hasSuffix:
|
||||
return strings.HasPrefix(key, strings.TrimSuffix(pattern, "*"))
|
||||
case hasPrefix:
|
||||
return strings.HasSuffix(key, strings.TrimPrefix(pattern, "*"))
|
||||
default:
|
||||
return pattern == key
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateOp does the numeric comparison. within_pct is the oddball:
|
||||
// it tests |observed - nominal| <= (pct / 100) * nominal. Returns an
|
||||
// error for unknown operators so the caller can log + drop.
|
||||
func evaluateOp(op ThresholdOp, observed, threshold, nominal float64) (bool, error) {
|
||||
switch op {
|
||||
case OpLT:
|
||||
return observed < threshold, nil
|
||||
case OpLTE:
|
||||
return observed <= threshold, nil
|
||||
case OpGT:
|
||||
return observed > threshold, nil
|
||||
case OpGTE:
|
||||
return observed >= threshold, nil
|
||||
case OpWithinPct:
|
||||
if nominal == 0 {
|
||||
// within_pct against a 0 nominal is meaningless. Treat as
|
||||
// pass so a misconfigured rule doesn't spuriously fail.
|
||||
return true, nil
|
||||
}
|
||||
allowed := (threshold / 100.0) * nominal
|
||||
if allowed < 0 {
|
||||
allowed = -allowed
|
||||
}
|
||||
diff := observed - nominal
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
return diff <= allowed, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown op %q", op)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package orchestrator
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestEvaluate_Ops covers every operator against the boundary case
|
||||
// (equal to threshold) plus one clearly-inside and one clearly-outside
|
||||
// value. Table-driven because the logic is regular.
|
||||
func TestEvaluate_Ops(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
op ThresholdOp
|
||||
value float64
|
||||
nominal float64
|
||||
observed float64
|
||||
want bool
|
||||
}{
|
||||
{"lt strict below", OpLT, 10, 0, 5, true},
|
||||
{"lt equal fails", OpLT, 10, 0, 10, false},
|
||||
{"lt above fails", OpLT, 10, 0, 15, false},
|
||||
|
||||
{"lte below", OpLTE, 10, 0, 5, true},
|
||||
{"lte equal passes", OpLTE, 10, 0, 10, true},
|
||||
{"lte above fails", OpLTE, 10, 0, 11, false},
|
||||
|
||||
{"gt below fails", OpGT, 900, 0, 800, false},
|
||||
{"gt equal fails", OpGT, 900, 0, 900, false},
|
||||
{"gt above passes", OpGT, 900, 0, 950, true},
|
||||
|
||||
{"gte equal passes", OpGTE, 900, 0, 900, true},
|
||||
{"gte below fails", OpGTE, 900, 0, 800, false},
|
||||
|
||||
{"within_pct exact", OpWithinPct, 5, 12.0, 12.0, true},
|
||||
{"within_pct inside", OpWithinPct, 5, 12.0, 11.7, true},
|
||||
{"within_pct outside low", OpWithinPct, 5, 12.0, 11.0, false},
|
||||
{"within_pct outside high", OpWithinPct, 5, 12.0, 12.7, false},
|
||||
{"within_pct zero nominal passes", OpWithinPct, 5, 0, 99, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rules := []Threshold{{
|
||||
Stage: "*", Kind: "k", Key: "k", Op: tc.op,
|
||||
Value: tc.value, Nominal: tc.nominal, Severity: SeverityCritical,
|
||||
}}
|
||||
res := Evaluate(Sample{Stage: "Any", Kind: "k", Key: "k", Value: tc.observed}, rules)
|
||||
if len(res) != 1 {
|
||||
t.Fatalf("expected 1 match, got %d", len(res))
|
||||
}
|
||||
if res[0].Passed != tc.want {
|
||||
t.Fatalf("op=%s observed=%v want passed=%v got %v", tc.op, tc.observed, tc.want, res[0].Passed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluate_StageMatching: a Network-scoped rule ignores samples
|
||||
// stamped with other stages. Global "*" catches everything.
|
||||
func TestEvaluate_StageMatching(t *testing.T) {
|
||||
rules := []Threshold{
|
||||
{Stage: "*", Kind: "temp", Key: "cpu/*", Op: OpLT, Value: 92, Severity: SeverityCritical},
|
||||
{Stage: "Burn", Kind: "temp", Key: "cpu/*", Op: OpLT, Value: 88, Severity: SeverityCritical},
|
||||
}
|
||||
// Sample from CPUStress — only the global rule applies.
|
||||
res := Evaluate(Sample{Stage: "CPUStress", Kind: "temp", Key: "cpu/0", Value: 89}, rules)
|
||||
if len(res) != 1 {
|
||||
t.Fatalf("cpustress sample: expected 1 match, got %d", len(res))
|
||||
}
|
||||
if res[0].Threshold.Value != 92 {
|
||||
t.Fatalf("cpustress sample matched wrong rule: %+v", res[0].Threshold)
|
||||
}
|
||||
|
||||
// Sample from Burn — both rules match. The stricter one breaches.
|
||||
res = Evaluate(Sample{Stage: "Burn", Kind: "temp", Key: "cpu/0", Value: 89}, rules)
|
||||
if len(res) != 2 {
|
||||
t.Fatalf("burn sample: expected 2 matches, got %d", len(res))
|
||||
}
|
||||
var globalPassed, burnPassed bool
|
||||
for _, r := range res {
|
||||
switch r.Threshold.Value {
|
||||
case 92:
|
||||
globalPassed = r.Passed
|
||||
case 88:
|
||||
burnPassed = r.Passed
|
||||
}
|
||||
}
|
||||
if !globalPassed {
|
||||
t.Fatalf("global 92C rule should pass at 89C")
|
||||
}
|
||||
if burnPassed {
|
||||
t.Fatalf("burn 88C rule should breach at 89C")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluate_KeyWildcards covers "*" / "prefix*" / "*suffix".
|
||||
func TestEvaluate_KeyWildcards(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern string
|
||||
key string
|
||||
match bool
|
||||
}{
|
||||
{"*", "anything", true},
|
||||
{"", "anything", true},
|
||||
{"cpu/*", "cpu/0", true},
|
||||
{"cpu/*", "gpu/0", false},
|
||||
{"*/rate", "eth0/rate", true},
|
||||
{"*/rate", "eth0/count", false},
|
||||
{"exact", "exact", true},
|
||||
{"exact", "exactly", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.pattern+"_vs_"+tc.key, func(t *testing.T) {
|
||||
got := keyMatches(tc.pattern, tc.key)
|
||||
if got != tc.match {
|
||||
t.Fatalf("keyMatches(%q, %q) = %v, want %v", tc.pattern, tc.key, got, tc.match)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluate_SeverityDispatch: only critical breaches flip
|
||||
// CriticalBreach; warning-severity breaches stay advisory.
|
||||
func TestEvaluate_SeverityDispatch(t *testing.T) {
|
||||
rules := []Threshold{
|
||||
{Stage: "*", Kind: "temp", Key: "cpu", Op: OpLT, Value: 92, Severity: SeverityCritical},
|
||||
{Stage: "*", Kind: "fio", Key: "p99", Op: OpLT, Value: 50000, Severity: SeverityWarning},
|
||||
}
|
||||
res := Evaluate(Sample{Stage: "CPU", Kind: "temp", Key: "cpu", Value: 95}, rules)
|
||||
if len(res) != 1 || !res[0].CriticalBreach() {
|
||||
t.Fatalf("critical breach not detected: %+v", res)
|
||||
}
|
||||
res = Evaluate(Sample{Stage: "Storage", Kind: "fio", Key: "p99", Value: 80000}, rules)
|
||||
if len(res) != 1 {
|
||||
t.Fatalf("expected 1 match, got %d", len(res))
|
||||
}
|
||||
if res[0].CriticalBreach() {
|
||||
t.Fatalf("warning-severity breach should not be critical")
|
||||
}
|
||||
if !res[0].Breached() {
|
||||
t.Fatalf("warning-severity rule should still show breach=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluate_NoMatchingThreshold: a sample that doesn't hit any rule
|
||||
// produces an empty result slice — callers treat that as "advisory".
|
||||
func TestEvaluate_NoMatchingThreshold(t *testing.T) {
|
||||
rules := []Threshold{
|
||||
{Stage: "*", Kind: "temp", Key: "cpu/*", Op: OpLT, Value: 92, Severity: SeverityCritical},
|
||||
}
|
||||
res := Evaluate(Sample{Stage: "Network", Kind: "iperf", Key: "throughput", Value: 950}, rules)
|
||||
if len(res) != 0 {
|
||||
t.Fatalf("unmatched sample should yield 0 results, got %d", len(res))
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,17 @@ type Data struct {
|
||||
Host model.Host
|
||||
Stages []model.Stage
|
||||
SpecDiffs []model.SpecDiff
|
||||
Aggregates []Aggregate // flattened measurement summary; see Aggregate
|
||||
Aggregates []Aggregate // flattened measurement summary; see Aggregate
|
||||
Firmware []FirmwareSnapshot // captured firmware versions, empty if none
|
||||
}
|
||||
|
||||
// FirmwareSnapshot is the report-facing view of one firmware row.
|
||||
// Package-local so the HTML template stays decoupled from store types.
|
||||
type FirmwareSnapshot struct {
|
||||
Component string
|
||||
Identifier string
|
||||
Version string
|
||||
Vendor string
|
||||
}
|
||||
|
||||
// Aggregate is a per (kind, key) summary of a run's measurements. Min/
|
||||
@@ -196,6 +206,27 @@ const htmlTemplate = `<!doctype html>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Firmware ({{len .Firmware}})</h2>
|
||||
{{if .Firmware}}
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Identifier</th><th>Version</th><th>Vendor</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Firmware}}
|
||||
<tr>
|
||||
<td>{{.Component}}</td>
|
||||
<td><code>{{.Identifier}}</code></td>
|
||||
<td><code>{{.Version}}</code></td>
|
||||
<td>{{.Vendor}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p>No firmware snapshots captured.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Spec diffs ({{len .SpecDiffs}})</h2>
|
||||
{{if .SpecDiffs}}
|
||||
|
||||
+97
-5
@@ -21,11 +21,36 @@ import (
|
||||
)
|
||||
|
||||
type Spec struct {
|
||||
CPU *CPUSpec `yaml:"cpu,omitempty"`
|
||||
Memory *MemorySpec `yaml:"memory,omitempty"`
|
||||
Disks []DiskSpec `yaml:"disks,omitempty"`
|
||||
NICs []NICSpec `yaml:"nics,omitempty"`
|
||||
GPUs []GPUSpec `yaml:"gpus,omitempty"`
|
||||
CPU *CPUSpec `yaml:"cpu,omitempty"`
|
||||
Memory *MemorySpec `yaml:"memory,omitempty"`
|
||||
Disks []DiskSpec `yaml:"disks,omitempty"`
|
||||
NICs []NICSpec `yaml:"nics,omitempty"`
|
||||
GPUs []GPUSpec `yaml:"gpus,omitempty"`
|
||||
Firmware []FirmwareSpec `yaml:"firmware,omitempty"`
|
||||
}
|
||||
|
||||
// FirmwareSpec is one row in the expected-spec YAML's `firmware:` block.
|
||||
// Component is one of bios|bmc|nic|hba|microcode|nvme_fw (matches the
|
||||
// on-wire value from agent/probes.FirmwareSnapshot.Component). Identifier
|
||||
// is optional — when empty the rule applies to every observed snapshot
|
||||
// of that component (use for single-instance things like BIOS/microcode);
|
||||
// when set it pins the check to a specific NIC port / NVMe controller /
|
||||
// PCI address. Version is the literal string expected; comparison is
|
||||
// exact after trimming whitespace.
|
||||
type FirmwareSpec struct {
|
||||
Component string `yaml:"component"`
|
||||
Identifier string `yaml:"identifier,omitempty"`
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
|
||||
// FirmwareObserved is what the agent reported, in a spec-package-local
|
||||
// shape so callers don't need to thread store types through the diff.
|
||||
// The server converts store.FirmwareSnapshot → FirmwareObserved before
|
||||
// calling DiffFirmware.
|
||||
type FirmwareObserved struct {
|
||||
Component string
|
||||
Identifier string
|
||||
Version string
|
||||
}
|
||||
|
||||
type CPUSpec struct {
|
||||
@@ -175,6 +200,73 @@ func diffNICs(expected, actual []NICSpec) []model.SpecDiff {
|
||||
return out
|
||||
}
|
||||
|
||||
// DiffFirmware returns a SpecDiff per firmware expectation that doesn't
|
||||
// find a matching observed snapshot. Matching rules:
|
||||
// - An expected rule with Identifier set matches by (component, id);
|
||||
// a missing observed snapshot yields a "present=false" diff.
|
||||
// - An expected rule with Identifier empty applies to every observed
|
||||
// snapshot of that component — useful for "all NICs must run fw
|
||||
// 8.30" without listing each port. Zero observed snapshots of the
|
||||
// component yields a single "present=false" diff, not N.
|
||||
// - Version mismatch emits an exact-string expected→actual diff.
|
||||
// Case is preserved (firmware versions are case-sensitive in practice).
|
||||
func DiffFirmware(expected []FirmwareSpec, actual []FirmwareObserved) []model.SpecDiff {
|
||||
if len(expected) == 0 {
|
||||
return nil
|
||||
}
|
||||
byCompIdent := map[string]FirmwareObserved{}
|
||||
byComp := map[string][]FirmwareObserved{}
|
||||
for _, o := range actual {
|
||||
byCompIdent[fwKey(o.Component, o.Identifier)] = o
|
||||
byComp[o.Component] = append(byComp[o.Component], o)
|
||||
}
|
||||
var out []model.SpecDiff
|
||||
for _, exp := range expected {
|
||||
comp := strings.TrimSpace(exp.Component)
|
||||
if comp == "" || strings.TrimSpace(exp.Version) == "" {
|
||||
continue
|
||||
}
|
||||
label := "firmware[" + comp
|
||||
if exp.Identifier != "" {
|
||||
label += "/" + exp.Identifier
|
||||
}
|
||||
label += "]"
|
||||
if exp.Identifier != "" {
|
||||
got, ok := byCompIdent[fwKey(comp, exp.Identifier)]
|
||||
if !ok {
|
||||
out = append(out, diff(label+".present", "true", "false"))
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(got.Version), strings.TrimSpace(exp.Version)) {
|
||||
out = append(out, diff(label+".version", exp.Version, got.Version))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// No identifier: fan out across every observed snapshot of this
|
||||
// component. Missing is one diff; a mismatching port/controller
|
||||
// emits one diff per mismatch.
|
||||
observed := byComp[comp]
|
||||
if len(observed) == 0 {
|
||||
out = append(out, diff(label+".present", "true", "false"))
|
||||
continue
|
||||
}
|
||||
for _, got := range observed {
|
||||
if !strings.EqualFold(strings.TrimSpace(got.Version), strings.TrimSpace(exp.Version)) {
|
||||
slot := got.Identifier
|
||||
if slot == "" {
|
||||
slot = "*"
|
||||
}
|
||||
out = append(out, diff("firmware["+comp+"/"+slot+"].version", exp.Version, got.Version))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fwKey(component, identifier string) string {
|
||||
return strings.ToLower(component) + "|" + strings.ToLower(identifier)
|
||||
}
|
||||
|
||||
func diffGPUs(expected, actual []GPUSpec) []model.SpecDiff {
|
||||
if len(expected) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -119,3 +119,96 @@ func TestDiffSeverityAlwaysCritical(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFirmwareIdentifierMatch(t *testing.T) {
|
||||
exp := []FirmwareSpec{{Component: "bios", Version: "3.2"}}
|
||||
obs := []FirmwareObserved{{Component: "bios", Identifier: "system", Version: "3.2"}}
|
||||
if d := DiffFirmware(exp, obs); len(d) != 0 {
|
||||
t.Fatalf("matching bios version should produce no diff, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFirmwareVersionMismatch(t *testing.T) {
|
||||
exp := []FirmwareSpec{{Component: "bios", Version: "3.3"}}
|
||||
obs := []FirmwareObserved{{Component: "bios", Identifier: "system", Version: "3.2"}}
|
||||
d := DiffFirmware(exp, obs)
|
||||
if len(d) != 1 {
|
||||
t.Fatalf("want 1 diff, got %d: %+v", len(d), d)
|
||||
}
|
||||
if d[0].Expected != "3.3" || d[0].Actual != "3.2" {
|
||||
t.Fatalf("diff expected/actual = %q/%q, want 3.3/3.2", d[0].Expected, d[0].Actual)
|
||||
}
|
||||
if d[0].Severity != "critical" {
|
||||
t.Errorf("severity = %q, want critical", d[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFirmwareMissingComponentPresent(t *testing.T) {
|
||||
// Expected rule with no identifier + zero observed snapshots →
|
||||
// single "present=false" diff, not N.
|
||||
exp := []FirmwareSpec{{Component: "bmc", Version: "1.74"}}
|
||||
d := DiffFirmware(exp, nil)
|
||||
if len(d) != 1 {
|
||||
t.Fatalf("want 1 diff for missing BMC, got %d: %+v", len(d), d)
|
||||
}
|
||||
if d[0].Field != "firmware[bmc].present" || d[0].Expected != "true" || d[0].Actual != "false" {
|
||||
t.Fatalf("missing-BMC diff = %+v", d[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFirmwareWildcardFanOut(t *testing.T) {
|
||||
// Expected rule with empty identifier fans across every observed
|
||||
// snapshot of the component — one port matches, one doesn't → one diff.
|
||||
exp := []FirmwareSpec{{Component: "nic", Version: "16.32.1010"}}
|
||||
obs := []FirmwareObserved{
|
||||
{Component: "nic", Identifier: "eth0", Version: "16.32.1010"},
|
||||
{Component: "nic", Identifier: "eth1", Version: "14.28.0000"},
|
||||
}
|
||||
d := DiffFirmware(exp, obs)
|
||||
if len(d) != 1 {
|
||||
t.Fatalf("want 1 diff (mismatched eth1 only), got %d: %+v", len(d), d)
|
||||
}
|
||||
if d[0].Field != "firmware[nic/eth1].version" {
|
||||
t.Errorf("field = %q, want firmware[nic/eth1].version", d[0].Field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFirmwareIdentifierPin(t *testing.T) {
|
||||
// Identifier set: pins the rule to a specific port. Other ports
|
||||
// with mismatched firmware are not evaluated by this rule.
|
||||
exp := []FirmwareSpec{{Component: "nic", Identifier: "eth0", Version: "1.0"}}
|
||||
obs := []FirmwareObserved{
|
||||
{Component: "nic", Identifier: "eth0", Version: "1.0"},
|
||||
{Component: "nic", Identifier: "eth1", Version: "9.9"},
|
||||
}
|
||||
if d := DiffFirmware(exp, obs); len(d) != 0 {
|
||||
t.Fatalf("pinned rule should ignore other ports, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFirmwareIdentifierPinMissing(t *testing.T) {
|
||||
// Pinned rule with no matching observed snapshot → present=false diff.
|
||||
exp := []FirmwareSpec{{Component: "nic", Identifier: "eth0", Version: "1.0"}}
|
||||
if d := DiffFirmware(exp, nil); len(d) != 1 || d[0].Field != "firmware[nic/eth0].present" {
|
||||
t.Fatalf("want present=false for pinned rule, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFirmwareEmptyRuleSkipped(t *testing.T) {
|
||||
// Empty component or empty version silently skip rather than panic.
|
||||
exp := []FirmwareSpec{{Component: "", Version: "x"}, {Component: "bios", Version: ""}}
|
||||
obs := []FirmwareObserved{{Component: "bios", Identifier: "system", Version: "3.2"}}
|
||||
if d := DiffFirmware(exp, obs); len(d) != 0 {
|
||||
t.Fatalf("empty rules should skip, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffFirmwareCaseInsensitive(t *testing.T) {
|
||||
// Version match is case-insensitive after trim; avoids spurious diff
|
||||
// from ethtool's "FW1234" vs expected YAML's "fw1234".
|
||||
exp := []FirmwareSpec{{Component: "nvme_fw", Identifier: "nvme0", Version: "fw1234"}}
|
||||
obs := []FirmwareObserved{{Component: "nvme_fw", Identifier: "nvme0", Version: "FW1234"}}
|
||||
if d := DiffFirmware(exp, obs); len(d) != 0 {
|
||||
t.Fatalf("case-insensitive match expected, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FirmwareSnapshot is one row in firmware_snapshots. A run captures
|
||||
// many (one per BIOS/BMC/NIC/HBA/microcode/NVMe) so SpecValidate can
|
||||
// diff them against the host's expected spec in Phase 4.
|
||||
type FirmwareSnapshot struct {
|
||||
ID int64
|
||||
RunID int64
|
||||
Component string // bios|bmc|nic|hba|microcode|nvme_fw
|
||||
Identifier string // slot/serial/device path
|
||||
Version string
|
||||
Vendor string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
// Firmware is the CRUD seam. The agent's Phase-4 probe POSTs captured
|
||||
// rows; the orchestrator stores them. SpecValidate reads them back.
|
||||
type Firmware struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// Create inserts a single firmware snapshot. One call per (run, component,
|
||||
// identifier) — the agent probe owns dedup/formatting.
|
||||
func (f *Firmware) Create(ctx context.Context, s FirmwareSnapshot) (int64, error) {
|
||||
raw := s.RawJSON
|
||||
if raw == "" {
|
||||
raw = "{}"
|
||||
}
|
||||
res, err := f.DB.ExecContext(ctx, `
|
||||
INSERT INTO firmware_snapshots(run_id, component, identifier, version, vendor, raw_json)
|
||||
VALUES(?,?,?,?,?,?)
|
||||
`, s.RunID, s.Component, s.Identifier, s.Version, s.Vendor, raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert firmware: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// CreateBatch persists a slice of snapshots under one transaction.
|
||||
// Agent probe enumerates all components in one pass, so batching wins.
|
||||
func (f *Firmware) CreateBatch(ctx context.Context, rows []FirmwareSnapshot) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := f.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO firmware_snapshots(run_id, component, identifier, version, vendor, raw_json)
|
||||
VALUES(?,?,?,?,?,?)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare firmware insert: %w", err)
|
||||
}
|
||||
defer func() { _ = stmt.Close() }()
|
||||
for _, s := range rows {
|
||||
raw := s.RawJSON
|
||||
if raw == "" {
|
||||
raw = "{}"
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx, s.RunID, s.Component, s.Identifier, s.Version, s.Vendor, raw); err != nil {
|
||||
return fmt.Errorf("insert firmware %s/%s: %w", s.Component, s.Identifier, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListForRun returns every firmware snapshot for a run in stable order.
|
||||
// Report page + SpecValidate both read this.
|
||||
func (f *Firmware) ListForRun(ctx context.Context, runID int64) ([]FirmwareSnapshot, error) {
|
||||
rows, err := f.DB.QueryContext(ctx, `
|
||||
SELECT id, run_id, component, identifier, version, vendor, raw_json
|
||||
FROM firmware_snapshots WHERE run_id = ? ORDER BY id
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []FirmwareSnapshot
|
||||
for rows.Next() {
|
||||
var s FirmwareSnapshot
|
||||
if err := rows.Scan(&s.ID, &s.RunID, &s.Component, &s.Identifier,
|
||||
&s.Version, &s.Vendor, &s.RawJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
+30
-12
@@ -14,16 +14,30 @@ type Runs struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// Create inserts a new run using the default "quick" profile. Older
|
||||
// call sites (and most tests) target this form — the profile column's
|
||||
// DEFAULT 'quick' on runs takes care of the backfill.
|
||||
func (r *Runs) Create(ctx context.Context, hostID int64, tokenHash string, nonDestructive bool) (int64, error) {
|
||||
return r.CreateWithProfile(ctx, hostID, tokenHash, nonDestructive, "quick")
|
||||
}
|
||||
|
||||
// CreateWithProfile inserts a new run with an explicit profile
|
||||
// ("quick"|"deep"|"soak"). The UI handler is the authoritative caller;
|
||||
// empty profile falls back to "quick" so a misconfigured form doesn't
|
||||
// leave a row with a blank profile column.
|
||||
func (r *Runs) CreateWithProfile(ctx context.Context, hostID int64, tokenHash string, nonDestructive bool, profile string) (int64, error) {
|
||||
if profile == "" {
|
||||
profile = "quick"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
nd := 0
|
||||
if nonDestructive {
|
||||
nd = 1
|
||||
}
|
||||
res, err := r.DB.ExecContext(ctx, `
|
||||
INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at, non_destructive)
|
||||
VALUES(?,?,?,?,?,?)
|
||||
`, hostID, string(model.StateQueued), tokenHash, "linux", now, nd)
|
||||
INSERT INTO runs(host_id, state, agent_token_hash, next_boot_target, started_at, non_destructive, profile)
|
||||
VALUES(?,?,?,?,?,?,?)
|
||||
`, hostID, string(model.StateQueued), tokenHash, "linux", now, nd, profile)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert run: %w", err)
|
||||
}
|
||||
@@ -107,14 +121,15 @@ func (r *Runs) Get(ctx context.Context, id int64) (*model.Run, error) {
|
||||
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)
|
||||
COALESCE(override_flags_json,''), COALESCE(non_destructive,0),
|
||||
COALESCE(profile,'quick')
|
||||
FROM runs WHERE id = ?
|
||||
`, id)
|
||||
var run model.Run
|
||||
var completedAt sql.NullTime
|
||||
err := row.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)
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -133,7 +148,8 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
|
||||
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)
|
||||
COALESCE(override_flags_json,''), COALESCE(non_destructive,0),
|
||||
COALESCE(profile,'quick')
|
||||
FROM runs WHERE host_id = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`, hostID)
|
||||
@@ -141,7 +157,7 @@ func (r *Runs) LatestForHost(ctx context.Context, hostID int64) (*model.Run, err
|
||||
var completedAt sql.NullTime
|
||||
err := row.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)
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -165,7 +181,8 @@ func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]mode
|
||||
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)
|
||||
COALESCE(override_flags_json,''), COALESCE(non_destructive,0),
|
||||
COALESCE(profile,'quick')
|
||||
FROM runs
|
||||
WHERE host_id = ?
|
||||
ORDER BY id DESC
|
||||
@@ -181,7 +198,7 @@ func (r *Runs) ListForHost(ctx context.Context, hostID int64, limit int) ([]mode
|
||||
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 {
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if completedAt.Valid {
|
||||
@@ -206,7 +223,8 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
|
||||
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)
|
||||
COALESCE(override_flags_json,''), COALESCE(non_destructive,0),
|
||||
COALESCE(profile,'quick')
|
||||
FROM runs
|
||||
WHERE state NOT IN ('Completed','Released','Cancelled')
|
||||
ORDER BY id
|
||||
@@ -221,7 +239,7 @@ func (r *Runs) Active(ctx context.Context) ([]model.Run, error) {
|
||||
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 {
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if completedAt.Valid {
|
||||
@@ -275,7 +293,7 @@ func (r *Runs) FindActiveByMAC(ctx context.Context, mac string) (*model.Run, err
|
||||
var completedAt sql.NullTime
|
||||
err := row.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)
|
||||
&completedAt, &run.ReportPath, &run.HoldIP, &run.OverrideFlagsJSON, &run.NonDestructive, &run.Profile)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ type Stages struct {
|
||||
// reaches Inventory; later phases add more executors but the list is fixed.
|
||||
var DefaultStageOrder = []string{
|
||||
"Inventory",
|
||||
"Firmware",
|
||||
"SpecValidate",
|
||||
"SMART",
|
||||
"CPUStress",
|
||||
"Storage",
|
||||
"Network",
|
||||
"Burn",
|
||||
"GPU",
|
||||
"PSU",
|
||||
"Reporting",
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Threshold is the DB view of a per-run threshold row. Mirrors the
|
||||
// orchestrator.Threshold value-object but keeps Severity/Op as strings
|
||||
// so callers higher up don't force this package to import orchestrator.
|
||||
type Threshold struct {
|
||||
ID int64
|
||||
RunID int64
|
||||
Stage string
|
||||
Kind string
|
||||
Key string
|
||||
Op string
|
||||
Threshold float64
|
||||
Nominal float64
|
||||
Unit string
|
||||
Severity string
|
||||
Source string // profile|host_override
|
||||
}
|
||||
|
||||
// ThresholdEvaluation is one recorded comparison — the evaluator calls
|
||||
// this for every sample that matched a threshold, whether it passed
|
||||
// or breached. The report page aggregates these to show the operator
|
||||
// why a run failed (or was flagged as warning-only).
|
||||
type ThresholdEvaluation struct {
|
||||
ID int64
|
||||
RunID int64
|
||||
ThresholdID int64
|
||||
Stage string
|
||||
Kind string
|
||||
Key string
|
||||
TS time.Time
|
||||
Observed float64
|
||||
Passed bool
|
||||
}
|
||||
|
||||
// Thresholds is the CRUD seam. Kept intentionally narrow: seed at run
|
||||
// creation, list for evaluation on each sensor batch, record eval
|
||||
// results, aggregate for the report.
|
||||
type Thresholds struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// ThresholdSpec is the caller-supplied shape for seeding — a flat
|
||||
// value-object that carries the threshold rule plus its source so
|
||||
// the ProfileRegistry-driven seed and per-host overrides converge
|
||||
// on one insert path. Kept here (not in config) so the store layer
|
||||
// doesn't have to import config.
|
||||
type ThresholdSpec struct {
|
||||
Stage string
|
||||
Kind string
|
||||
Key string
|
||||
Op string
|
||||
Value float64
|
||||
Nominal float64
|
||||
Unit string
|
||||
Severity string
|
||||
Source string
|
||||
}
|
||||
|
||||
// SeedForRun converts the caller's specs into Threshold rows for the
|
||||
// given run and bulk-inserts them. Returns the inserted rows with IDs
|
||||
// populated so the evaluator can pin evaluations without a re-read.
|
||||
func (t *Thresholds) SeedForRun(ctx context.Context, runID int64, specs []ThresholdSpec) ([]Threshold, error) {
|
||||
rows := make([]Threshold, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
rows = append(rows, Threshold{
|
||||
RunID: runID,
|
||||
Stage: s.Stage,
|
||||
Kind: s.Kind,
|
||||
Key: s.Key,
|
||||
Op: s.Op,
|
||||
Threshold: s.Value,
|
||||
Nominal: s.Nominal,
|
||||
Unit: s.Unit,
|
||||
Severity: s.Severity,
|
||||
Source: s.Source,
|
||||
})
|
||||
}
|
||||
return t.CreateBatch(ctx, rows)
|
||||
}
|
||||
|
||||
// Create inserts a single threshold row — used by the seed path when
|
||||
// the orchestrator materializes per-run rules from the ProfileRegistry.
|
||||
// Returns the row's ID so the evaluator can pin evaluations to it.
|
||||
func (t *Thresholds) Create(ctx context.Context, th Threshold) (int64, error) {
|
||||
res, err := t.DB.ExecContext(ctx, `
|
||||
INSERT INTO thresholds(run_id, stage_name, kind, key, op, threshold, nominal, unit, severity, source)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
`, th.RunID, th.Stage, th.Kind, th.Key, th.Op, th.Threshold, th.Nominal, th.Unit, th.Severity, th.Source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert threshold: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// CreateBatch is the fast path for run seeding — one transaction per
|
||||
// run, one row per threshold. Returns the inserted rows with IDs set
|
||||
// so the caller can drop them into the in-memory evaluator without a
|
||||
// follow-up read.
|
||||
func (t *Thresholds) CreateBatch(ctx context.Context, rows []Threshold) ([]Threshold, error) {
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
tx, err := t.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO thresholds(run_id, stage_name, kind, key, op, threshold, nominal, unit, severity, source)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare threshold insert: %w", err)
|
||||
}
|
||||
defer func() { _ = stmt.Close() }()
|
||||
out := make([]Threshold, 0, len(rows))
|
||||
for _, th := range rows {
|
||||
res, err := stmt.ExecContext(ctx, th.RunID, th.Stage, th.Kind, th.Key, th.Op,
|
||||
th.Threshold, th.Nominal, th.Unit, th.Severity, th.Source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert threshold %s/%s: %w", th.Stage, th.Key, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
th.ID = id
|
||||
out = append(out, th)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListForRun returns every threshold seeded for a run, in stable ID
|
||||
// order. Evaluator expects this to be cheap (few tens of rows per run)
|
||||
// and pulls it on each /sensor batch.
|
||||
func (t *Thresholds) ListForRun(ctx context.Context, runID int64) ([]Threshold, error) {
|
||||
rows, err := t.DB.QueryContext(ctx, `
|
||||
SELECT id, run_id, stage_name, kind, key, op, threshold, nominal, unit, severity, source
|
||||
FROM thresholds WHERE run_id = ? ORDER BY id
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Threshold
|
||||
for rows.Next() {
|
||||
var th Threshold
|
||||
if err := rows.Scan(&th.ID, &th.RunID, &th.Stage, &th.Kind, &th.Key,
|
||||
&th.Op, &th.Threshold, &th.Nominal, &th.Unit, &th.Severity, &th.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, th)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecordEvaluation persists a single evaluation outcome. Called per
|
||||
// matching sample so the run's report has a full audit trail ("temp
|
||||
// hit 95 at 14:22:03" rather than just "temp failed").
|
||||
func (t *Thresholds) RecordEvaluation(ctx context.Context, ev ThresholdEvaluation) error {
|
||||
passed := 0
|
||||
if ev.Passed {
|
||||
passed = 1
|
||||
}
|
||||
if ev.TS.IsZero() {
|
||||
ev.TS = time.Now().UTC()
|
||||
}
|
||||
_, err := t.DB.ExecContext(ctx, `
|
||||
INSERT INTO threshold_evaluations(run_id, threshold_id, stage_name, kind, key, ts, observed, passed)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
`, ev.RunID, ev.ThresholdID, ev.Stage, ev.Kind, ev.Key, ev.TS, ev.Observed, passed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record evaluation: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordBatch persists a slice of evaluations in one transaction. The
|
||||
// agent-handler hot path builds these one per sample and batches them
|
||||
// under the same Sensor POST so we take one round-trip rather than N.
|
||||
func (t *Thresholds) RecordBatch(ctx context.Context, evals []ThresholdEvaluation) error {
|
||||
if len(evals) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := t.DB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO threshold_evaluations(run_id, threshold_id, stage_name, kind, key, ts, observed, passed)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare eval insert: %w", err)
|
||||
}
|
||||
defer func() { _ = stmt.Close() }()
|
||||
for _, ev := range evals {
|
||||
passed := 0
|
||||
if ev.Passed {
|
||||
passed = 1
|
||||
}
|
||||
if ev.TS.IsZero() {
|
||||
ev.TS = time.Now().UTC()
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx, ev.RunID, ev.ThresholdID, ev.Stage, ev.Kind, ev.Key, ev.TS, ev.Observed, passed); err != nil {
|
||||
return fmt.Errorf("insert eval: %w", err)
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListEvaluations returns the evaluation history for a run, newest
|
||||
// last. Bounded at a sane cap so a pathological run with a sample-per-
|
||||
// second sidecar doesn't blow up the report page.
|
||||
func (t *Thresholds) ListEvaluations(ctx context.Context, runID int64) ([]ThresholdEvaluation, error) {
|
||||
rows, err := t.DB.QueryContext(ctx, `
|
||||
SELECT id, run_id, threshold_id, stage_name, kind, key, ts, observed, passed
|
||||
FROM threshold_evaluations WHERE run_id = ?
|
||||
ORDER BY id LIMIT 5000
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ThresholdEvaluation
|
||||
for rows.Next() {
|
||||
var ev ThresholdEvaluation
|
||||
var passed int
|
||||
if err := rows.Scan(&ev.ID, &ev.RunID, &ev.ThresholdID, &ev.Stage, &ev.Kind,
|
||||
&ev.Key, &ev.TS, &ev.Observed, &passed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev.Passed = passed == 1
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CriticalBreaches returns the evaluations that fire the "fail the
|
||||
// run" gate — critical-severity thresholds with passed=0. The
|
||||
// agent-handler calls this at /result close so an aggregate breach
|
||||
// (p99 latency > bound) still flips the run to FailedHolding even if
|
||||
// no single sample tripped the fast-fail path.
|
||||
func (t *Thresholds) CriticalBreaches(ctx context.Context, runID int64) ([]ThresholdEvaluation, error) {
|
||||
rows, err := t.DB.QueryContext(ctx, `
|
||||
SELECT e.id, e.run_id, e.threshold_id, e.stage_name, e.kind, e.key, e.ts, e.observed, e.passed
|
||||
FROM threshold_evaluations e
|
||||
JOIN thresholds t ON t.id = e.threshold_id
|
||||
WHERE e.run_id = ? AND e.passed = 0 AND t.severity = 'critical'
|
||||
ORDER BY e.id
|
||||
`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ThresholdEvaluation
|
||||
for rows.Next() {
|
||||
var ev ThresholdEvaluation
|
||||
var passed int
|
||||
if err := rows.Scan(&ev.ID, &ev.RunID, &ev.ThresholdID, &ev.Stage, &ev.Kind,
|
||||
&ev.Key, &ev.TS, &ev.Observed, &passed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev.Passed = passed == 1
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -636,6 +636,21 @@ body.bare main { max-width: none; }
|
||||
.run-failed-stage { color: var(--danger); }
|
||||
.run-failed-stage strong { font-family: var(--mono); }
|
||||
.run-diffs { color: var(--danger); }
|
||||
.run-profile-chip {
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
background: rgba(255,255,255,.05);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.run-profile-quick { color: var(--accent); border-color: rgba(60,130,246,.45); background: rgba(60,130,246,.08); }
|
||||
.run-profile-deep { color: #e5b94f; border-color: rgba(229,185,79,.45); background: rgba(229,185,79,.08); }
|
||||
.run-profile-soak { color: #d97a57; border-color: rgba(217,122,87,.45); background: rgba(217,122,87,.08); }
|
||||
|
||||
.hold-banner {
|
||||
background: rgba(229,100,102,.1);
|
||||
@@ -890,6 +905,17 @@ body.bare main { max-width: none; }
|
||||
.host-actions { padding: 0; }
|
||||
.host-actions-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.host-nd-toggle { display: inline-flex; gap: 6px; align-items: center; color: var(--text-dim); font-size: 13px; }
|
||||
.host-profile-picker {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 10px;
|
||||
display: inline-flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
.host-profile-picker legend { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; padding: 0 4px; }
|
||||
.host-profile-picker label { display: inline-flex; gap: 4px; align-items: center; font-family: var(--mono); font-size: 13px; cursor: pointer; }
|
||||
|
||||
.in-flight-banner-wrap { display: contents; }
|
||||
.in-flight-banner {
|
||||
|
||||
@@ -65,7 +65,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -88,7 +88,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 28, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -110,7 +110,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -123,7 +123,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 30, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -136,7 +136,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 31, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -149,7 +149,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 32, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -182,7 +182,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 43, Col: 99}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -195,7 +195,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 47, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -208,7 +208,7 @@ func ActiveStep(d ActiveStepData) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `active_step.templ`, Line: 48, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -102,6 +102,21 @@ templ HostActions(d HostPageData) {
|
||||
<div class="host-actions-row">
|
||||
if hostCanStart(d) {
|
||||
<form method="post" action={ templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)) } class="inline host-start-form">
|
||||
<fieldset class="host-profile-picker">
|
||||
<legend>Profile</legend>
|
||||
<label title="~10 min — post-repair sanity: all probes + gates, short budgets">
|
||||
<input type="radio" name="profile" value="quick" checked/>
|
||||
quick
|
||||
</label>
|
||||
<label title="~8–12 h — overnight soak: long CPU/RAM, full-disk fio verify, 30 min network">
|
||||
<input type="radio" name="profile" value="deep"/>
|
||||
deep
|
||||
</label>
|
||||
<label title="≥24 h — week-long burn-in; opt-in when you suspect intermittent faults">
|
||||
<input type="radio" name="profile" value="soak"/>
|
||||
soak
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="host-nd-toggle">
|
||||
<input type="checkbox" name="non_destructive" value="1"/>
|
||||
Non-destructive (skip wipe-probe + disk writes)
|
||||
@@ -258,6 +273,16 @@ func hostCanStartIfOnline(d HostPageData) bool {
|
||||
return d.ActiveRun == nil
|
||||
}
|
||||
|
||||
// profileChipValue normalizes a Run.Profile string for display on the
|
||||
// run page chip. Older runs with an empty column predate Phase 1 — show
|
||||
// them as "quick" (the prior implicit default).
|
||||
func profileChipValue(p string) string {
|
||||
if p == "" {
|
||||
return "quick"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// runDuration formats the elapsed time for a run using the same buckets
|
||||
// as stageDuration. In-flight runs clock from StartedAt to now so the
|
||||
// run-page header + runs-table row keep ticking on each SSE push.
|
||||
|
||||
@@ -361,7 +361,7 @@ func HostActions(d HostPageData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline host-start-form\"><label class=\"host-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive (skip wipe-probe + disk writes)</label> <button type=\"submit\" class=\"btn-primary\">Start vetting</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"inline host-start-form\"><fieldset class=\"host-profile-picker\"><legend>Profile</legend> <label title=\"~10 min — post-repair sanity: all probes + gates, short budgets\"><input type=\"radio\" name=\"profile\" value=\"quick\" checked> quick</label> <label title=\"~8–12 h — overnight soak: long CPU/RAM, full-disk fio verify, 30 min network\"><input type=\"radio\" name=\"profile\" value=\"deep\"> deep</label> <label title=\"≥24 h — week-long burn-in; opt-in when you suspect intermittent faults\"><input type=\"radio\" name=\"profile\" value=\"soak\"> soak</label></fieldset><label class=\"host-nd-toggle\"><input type=\"checkbox\" name=\"non_destructive\" value=\"1\"> Non-destructive (skip wipe-probe + disk writes)</label> <button type=\"submit\" class=\"btn-primary\">Start vetting</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -383,7 +383,7 @@ func HostActions(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var19 templ.SafeURL
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 116, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 131, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -428,7 +428,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 128, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 143, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -441,7 +441,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-inflight-%d", d.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 130, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 145, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -459,7 +459,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var23 templ.SafeURL
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.ActiveRun.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 134, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 149, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -472,7 +472,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", d.ActiveRun.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 135, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 150, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -485,7 +485,7 @@ func InFlightBanner(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.ActiveRun))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 136, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 151, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -541,7 +541,7 @@ func HostEmptyState(d HostPageData) templ.Component {
|
||||
var templ_7745c5c3_Var27 templ.SafeURL
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 152, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 167, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -655,7 +655,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 204, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 219, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -681,7 +681,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("runrow-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 206, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 221, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -694,7 +694,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var34 templ.SafeURL
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 210, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 225, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -707,7 +707,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("#%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 210, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 225, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -742,7 +742,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(&d.Run))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 213, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 228, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -755,7 +755,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(relativeTime(d.Run.StartedAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 215, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 230, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -768,7 +768,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 216, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 231, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -805,7 +805,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 221, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 236, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -823,7 +823,7 @@ func RunRow(d RunRowData) templ.Component {
|
||||
var templ_7745c5c3_Var44 templ.SafeURL
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/runs/%d", d.Run.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 226, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_page.templ`, Line: 241, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -867,6 +867,16 @@ func hostCanStartIfOnline(d HostPageData) bool {
|
||||
return d.ActiveRun == nil
|
||||
}
|
||||
|
||||
// profileChipValue normalizes a Run.Profile string for display on the
|
||||
// run page chip. Older runs with an empty column predate Phase 1 — show
|
||||
// them as "quick" (the prior implicit default).
|
||||
func profileChipValue(p string) string {
|
||||
if p == "" {
|
||||
return "quick"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// runDuration formats the elapsed time for a run using the same buckets
|
||||
// as stageDuration. In-flight runs clock from StartedAt to now so the
|
||||
// run-page header + runs-table row keep ticking on each SSE push.
|
||||
|
||||
@@ -55,7 +55,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("host-%d", t.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 19, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 19, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -68,7 +68,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -81,7 +81,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("tile-%d", t.Host.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 21, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 21, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -94,7 +94,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 24, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -107,7 +107,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("Open " + t.Host.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 24, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 24, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -120,7 +120,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Host.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 26, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 26, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -142,7 +142,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -155,7 +155,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(t.LastSeenAt))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 28, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 28, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -168,7 +168,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(t.Latest))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 29, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 29, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -186,7 +186,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 34, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 34, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -209,7 +209,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var14 templ.SafeURL
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", t.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 44, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 44, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -227,7 +227,7 @@ func HostTile(t TileData) templ.Component {
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", t.Latest.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_tile.templ`, Line: 48, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `host_tile.templ`, Line: 48, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -36,7 +36,7 @@ func Layout(title string) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 9, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 9, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -86,7 +86,7 @@ func BareLayout(title string) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 39, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 39, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -40,11 +40,13 @@ func runStateRank(s model.RunState) int {
|
||||
model.StateWaitingReboot,
|
||||
model.StateBooting,
|
||||
model.StateInventoryCheck,
|
||||
model.StateFirmware,
|
||||
model.StateSpecValidate,
|
||||
model.StateSMART,
|
||||
model.StateCPUStress,
|
||||
model.StateStorage,
|
||||
model.StateNetwork,
|
||||
model.StateBurn,
|
||||
model.StateGPU,
|
||||
model.StatePSU,
|
||||
model.StateReporting,
|
||||
@@ -205,11 +207,13 @@ func firstStageState(run *model.Run) model.RunState {
|
||||
func stageStateByName(name string) (model.RunState, bool) {
|
||||
m := map[string]model.RunState{
|
||||
"Inventory": model.StateInventoryCheck,
|
||||
"Firmware": model.StateFirmware,
|
||||
"SpecValidate": model.StateSpecValidate,
|
||||
"SMART": model.StateSMART,
|
||||
"CPUStress": model.StateCPUStress,
|
||||
"Storage": model.StateStorage,
|
||||
"Network": model.StateNetwork,
|
||||
"Burn": model.StateBurn,
|
||||
"GPU": model.StateGPU,
|
||||
"PSU": model.StatePSU,
|
||||
"Reporting": model.StateReporting,
|
||||
|
||||
@@ -48,11 +48,13 @@ func runStateRank(s model.RunState) int {
|
||||
model.StateWaitingReboot,
|
||||
model.StateBooting,
|
||||
model.StateInventoryCheck,
|
||||
model.StateFirmware,
|
||||
model.StateSpecValidate,
|
||||
model.StateSMART,
|
||||
model.StateCPUStress,
|
||||
model.StateStorage,
|
||||
model.StateNetwork,
|
||||
model.StateBurn,
|
||||
model.StateGPU,
|
||||
model.StatePSU,
|
||||
model.StateReporting,
|
||||
@@ -213,11 +215,13 @@ func firstStageState(run *model.Run) model.RunState {
|
||||
func stageStateByName(name string) (model.RunState, bool) {
|
||||
m := map[string]model.RunState{
|
||||
"Inventory": model.StateInventoryCheck,
|
||||
"Firmware": model.StateFirmware,
|
||||
"SpecValidate": model.StateSpecValidate,
|
||||
"SMART": model.StateSMART,
|
||||
"CPUStress": model.StateCPUStress,
|
||||
"Storage": model.StateStorage,
|
||||
"Network": model.StateNetwork,
|
||||
"Burn": model.StateBurn,
|
||||
"GPU": model.StateGPU,
|
||||
"PSU": model.StatePSU,
|
||||
"Reporting": model.StateReporting,
|
||||
@@ -312,7 +316,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
||||
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/pipeline.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -339,7 +343,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -361,7 +365,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
||||
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/pipeline.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -374,7 +378,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 275, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 279, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -387,7 +391,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 276, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 280, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -400,7 +404,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 277, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 281, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -454,7 +458,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 292, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 296, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -467,7 +471,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 294, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pipeline.templ`, Line: 298, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -8,26 +8,28 @@ import (
|
||||
)
|
||||
|
||||
// node indexes for the default pipeline layout: pre-stages (3) + stage
|
||||
// rows (9) + terminal Completed (1) = 13 nodes.
|
||||
// rows (11) + terminal Completed (1) = 15 nodes.
|
||||
const (
|
||||
idxQueued = 0
|
||||
idxWaitingReboot = 1
|
||||
idxBooting = 2
|
||||
idxInventory = 3
|
||||
idxSpecValidate = 4
|
||||
idxSMART = 5
|
||||
idxCPUStress = 6
|
||||
idxStorage = 7
|
||||
idxNetwork = 8
|
||||
idxGPU = 9
|
||||
idxPSU = 10
|
||||
idxReporting = 11
|
||||
idxCompleted = 12
|
||||
idxFirmware = 4
|
||||
idxSpecValidate = 5
|
||||
idxSMART = 6
|
||||
idxCPUStress = 7
|
||||
idxStorage = 8
|
||||
idxNetwork = 9
|
||||
idxBurn = 10
|
||||
idxGPU = 11
|
||||
idxPSU = 12
|
||||
idxReporting = 13
|
||||
idxCompleted = 14
|
||||
)
|
||||
|
||||
// seedStages returns a fresh all-pending stage slice in the canonical order.
|
||||
func seedStages() []model.Stage {
|
||||
names := []string{"Inventory", "SpecValidate", "SMART", "CPUStress", "Storage", "Network", "GPU", "PSU", "Reporting"}
|
||||
names := []string{"Inventory", "Firmware", "SpecValidate", "SMART", "CPUStress", "Storage", "Network", "Burn", "GPU", "PSU", "Reporting"}
|
||||
out := make([]model.Stage, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = model.Stage{Name: n, Ordinal: i, State: model.StagePending}
|
||||
@@ -37,10 +39,10 @@ func seedStages() []model.Stage {
|
||||
|
||||
func TestBuildPipeline_NoRun(t *testing.T) {
|
||||
nodes := BuildPipeline(nil, nil)
|
||||
// Ghost pipeline: 3 pre-stages + 9 stage ghosts + 1 terminal = 13
|
||||
// Ghost pipeline: 3 pre-stages + 10 stage ghosts + 1 terminal = 14
|
||||
// nodes, all pending.
|
||||
if len(nodes) != 13 {
|
||||
t.Fatalf("len = %d, want 13", len(nodes))
|
||||
if len(nodes) != 15 {
|
||||
t.Fatalf("len = %d, want 15", len(nodes))
|
||||
}
|
||||
for i, n := range nodes {
|
||||
if n.State != "pending" {
|
||||
@@ -56,8 +58,8 @@ func TestBuildPipeline_NoRun(t *testing.T) {
|
||||
func TestBuildPipeline_GhostStagesBeforeClaim(t *testing.T) {
|
||||
run := &model.Run{State: model.StateWaitingReboot}
|
||||
nodes := BuildPipeline(run, nil)
|
||||
if len(nodes) != 13 {
|
||||
t.Fatalf("len = %d, want 13", len(nodes))
|
||||
if len(nodes) != 15 {
|
||||
t.Fatalf("len = %d, want 15", len(nodes))
|
||||
}
|
||||
if nodes[idxQueued].State != "passed" {
|
||||
t.Errorf("Queued = %q, want passed", nodes[idxQueued].State)
|
||||
@@ -65,7 +67,7 @@ func TestBuildPipeline_GhostStagesBeforeClaim(t *testing.T) {
|
||||
if nodes[idxWaitingReboot].State != "running" {
|
||||
t.Errorf("WaitingReboot = %q, want running", nodes[idxWaitingReboot].State)
|
||||
}
|
||||
// All 9 stage ghosts must be pending — nothing has started yet.
|
||||
// All 11 stage ghosts must be pending — nothing has started yet.
|
||||
for i := idxInventory; i <= idxReporting; i++ {
|
||||
if nodes[i].State != "pending" {
|
||||
t.Errorf("%s (ghost) = %q, want pending", nodes[i].Name, nodes[i].State)
|
||||
@@ -81,19 +83,20 @@ func TestBuildPipeline_GhostStagesBeforeClaim(t *testing.T) {
|
||||
// pending ghosts rather than silently disappearing.
|
||||
func TestBuildPipeline_GhostStagesDuringStage(t *testing.T) {
|
||||
run := &model.Run{State: model.StateSMART}
|
||||
// Only Inventory + SpecValidate seeded; SMART onwards are ghosts.
|
||||
// Only Inventory + Firmware + SpecValidate seeded; SMART onwards are ghosts.
|
||||
stages := []model.Stage{
|
||||
{Name: "Inventory", Ordinal: 0, State: model.StagePassed},
|
||||
{Name: "SpecValidate", Ordinal: 1, State: model.StagePassed},
|
||||
{Name: "Firmware", Ordinal: 1, State: model.StagePassed},
|
||||
{Name: "SpecValidate", Ordinal: 2, State: model.StagePassed},
|
||||
}
|
||||
nodes := BuildPipeline(run, stages)
|
||||
if len(nodes) != 13 {
|
||||
t.Fatalf("len = %d, want 13", len(nodes))
|
||||
if len(nodes) != 15 {
|
||||
t.Fatalf("len = %d, want 15", len(nodes))
|
||||
}
|
||||
if nodes[idxSMART].State != "running" {
|
||||
t.Errorf("SMART (ghost) = %q, want running", nodes[idxSMART].State)
|
||||
}
|
||||
for _, i := range []int{idxCPUStress, idxStorage, idxNetwork, idxGPU, idxPSU, idxReporting} {
|
||||
for _, i := range []int{idxCPUStress, idxStorage, idxNetwork, idxBurn, idxGPU, idxPSU, idxReporting} {
|
||||
if nodes[i].State != "pending" {
|
||||
t.Errorf("%s (ghost) = %q, want pending", nodes[i].Name, nodes[i].State)
|
||||
}
|
||||
@@ -103,12 +106,13 @@ func TestBuildPipeline_GhostStagesDuringStage(t *testing.T) {
|
||||
func TestBuildPipeline_Running(t *testing.T) {
|
||||
run := &model.Run{State: model.StateSMART}
|
||||
stages := seedStages()
|
||||
stages[0].State = model.StagePassed
|
||||
stages[1].State = model.StagePassed
|
||||
stages[2].State = model.StageRunning
|
||||
stages[0].State = model.StagePassed // Inventory
|
||||
stages[1].State = model.StagePassed // Firmware
|
||||
stages[2].State = model.StagePassed // SpecValidate
|
||||
stages[3].State = model.StageRunning // SMART
|
||||
nodes := BuildPipeline(run, stages)
|
||||
if len(nodes) != 13 {
|
||||
t.Fatalf("len = %d, want 13", len(nodes))
|
||||
if len(nodes) != 15 {
|
||||
t.Fatalf("len = %d, want 15", len(nodes))
|
||||
}
|
||||
// Pre-stages are all past for a run that has reached SMART.
|
||||
for i := idxQueued; i <= idxBooting; i++ {
|
||||
@@ -136,10 +140,10 @@ func TestBuildPipeline_Running(t *testing.T) {
|
||||
func TestBuildPipeline_Failed(t *testing.T) {
|
||||
run := &model.Run{State: model.StateFailedHolding, FailedStage: "Storage"}
|
||||
stages := seedStages()
|
||||
for i := 0; i <= 3; i++ {
|
||||
for i := 0; i <= 4; i++ {
|
||||
stages[i].State = model.StagePassed
|
||||
}
|
||||
stages[4].State = model.StageFailed // Storage
|
||||
stages[5].State = model.StageFailed // Storage
|
||||
nodes := BuildPipeline(run, stages)
|
||||
// Pre-stages are past a run that reached Storage.
|
||||
for i := idxQueued; i <= idxBooting; i++ {
|
||||
@@ -150,7 +154,7 @@ func TestBuildPipeline_Failed(t *testing.T) {
|
||||
if nodes[idxStorage].State != "failed" {
|
||||
t.Errorf("Storage = %q, want failed", nodes[idxStorage].State)
|
||||
}
|
||||
for _, i := range []int{idxNetwork, idxGPU, idxPSU, idxReporting} {
|
||||
for _, i := range []int{idxNetwork, idxBurn, idxGPU, idxPSU, idxReporting} {
|
||||
if nodes[i].State != "skipped" {
|
||||
t.Errorf("%s = %q, want skipped", nodes[i].Name, nodes[i].State)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(form.Error)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 22, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 22, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -83,7 +83,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("curl -fsSL " + form.QuickRegisterURL + "/register/quick.sh | sudo bash")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 28, Col: 108}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 28, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -101,7 +101,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 38, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 38, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -114,7 +114,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.MAC)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 42, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 42, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -127,7 +127,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(form.WoLBroadcastIP)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 47, Col: 78}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 47, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -140,7 +140,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(defaultPort(form.WoLPort))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 51, Col: 78}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 51, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +153,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(form.ExpectedSpecYAML)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 56, Col: 127}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 56, Col: 127}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -166,7 +166,7 @@ func Registration(form RegistrationForm) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(form.Notes)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/registration.templ`, Line: 60, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `registration.templ`, Line: 60, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -83,6 +83,7 @@ templ RunHeader(d RunPageData) {
|
||||
<div class="run-header-left">
|
||||
<h1 class="run-header-name">{ fmt.Sprintf("Run #%d", d.Run.ID) }</h1>
|
||||
<span class={ "run-status-badge", "run-status-" + tileMood(&d.Run) }>{ tileStatus(&d.Run) }</span>
|
||||
<span class={ "run-profile-chip", "run-profile-" + profileChipValue(d.Run.Profile) }>{ profileChipValue(d.Run.Profile) }</span>
|
||||
<span class="run-duration">{ runDuration(&d.Run) }</span>
|
||||
if d.Run.FailedStage != "" {
|
||||
<span class="run-failed-stage">failed at <strong>{ d.Run.FailedStage }</strong></span>
|
||||
|
||||
@@ -286,142 +286,177 @@ func RunHeader(d RunPageData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span> <span class=\"run-duration\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 86, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
var templ_7745c5c3_Var15 = []any{"run-profile-chip", "run-profile-" + profileChipValue(d.Run.Profile)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var15).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, 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, 17, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(profileChipValue(d.Run.Profile))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 86, Col: 121}
|
||||
}
|
||||
_, 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, 18, "</span> <span class=\"run-duration\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(runDuration(&d.Run))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 87, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if d.Run.FailedStage != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"run-failed-stage\">failed at <strong>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"run-failed-stage\">failed at <strong>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(d.Run.FailedStage)
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(d.Run.FailedStage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 88, Col: 72}
|
||||
}
|
||||
_, 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, 18, "</strong></span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.SpecDiffCritical > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"run-diffs bad\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical diff", d.SpecDiffCritical))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 91, Col: 85}
|
||||
}
|
||||
_, 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, 20, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div><div class=\"run-header-right\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if canCancel(&d.Run) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 templ.SafeURL
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 96, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"inline\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"btn-danger\">Cancel run</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if canOverrideWipe(&d.Run) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 templ.SafeURL
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 101, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 89, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"inline\"><button type=\"submit\" class=\"btn-danger\">Override wipe-probe</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</strong></span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if hasReport(&d.Run) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a class=\"button-like\" href=\"")
|
||||
if d.SpecDiffCritical > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"run-diffs bad\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 templ.SafeURL
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Run.ID)))
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical diff", d.SpecDiffCritical))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 106, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 92, Col: 85}
|
||||
}
|
||||
_, 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, 27, "\" target=\"_blank\" rel=\"noopener\">View report</a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.Run.State.IsTerminal() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<form method=\"post\" action=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><div class=\"run-header-right\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if canCancel(&d.Run) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 templ.SafeURL
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/cancel", d.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 109, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 97, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"inline\"><button type=\"submit\" class=\"btn-primary\">Start new run</button></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" class=\"inline\" onsubmit=\"return confirm('Cancel run? Destructive stages may leave the host in an intermediate state requiring manual cleanup.');\"><button type=\"submit\" class=\"btn-danger\">Cancel run</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div></header>")
|
||||
if canOverrideWipe(&d.Run) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 templ.SafeURL
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 102, Col: 97}
|
||||
}
|
||||
_, 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, "\" class=\"inline\"><button type=\"submit\" class=\"btn-danger\">Override wipe-probe</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if hasReport(&d.Run) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<a class=\"button-like\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 templ.SafeURL
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Run.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 107, Col: 85}
|
||||
}
|
||||
_, 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, 30, "\" target=\"_blank\" rel=\"noopener\">View report</a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if d.Run.State.IsTerminal() {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 templ.SafeURL
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Host.ID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 110, Col: 89}
|
||||
}
|
||||
_, 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, 32, "\" class=\"inline\"><button type=\"submit\" class=\"btn-primary\">Start new run</button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div></header>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -449,83 +484,83 @@ func HoldBanner(d RunPageData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var22 == nil {
|
||||
templ_7745c5c3_Var22 = templ.NopComponent
|
||||
templ_7745c5c3_Var25 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var25 == nil {
|
||||
templ_7745c5c3_Var25 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if d.Run.State == model.StateFailedHolding && d.Run.HoldIP != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 124, Col: 47}
|
||||
}
|
||||
_, 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, 32, "\" class=\"hold-banner\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 126, Col: 53}
|
||||
}
|
||||
_, 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, 33, "\" hx-swap=\"outerHTML\"><span class=\"hold-banner-label\">Host is holding — SSH available:</span> <code class=\"hold-ssh\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.HoldKeyPath, d.Run.HoldIP))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 130, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</code></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<section id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<section id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 134, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 125, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" class=\"detail-hold-placeholder\" sse-swap=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"hold-banner\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 136, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 127, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-swap=\"outerHTML\"></section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" hx-swap=\"outerHTML\"><span class=\"hold-banner-label\">Host is holding — SSH available:</span> <code class=\"hold-ssh\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.HoldKeyPath, d.Run.HoldIP))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 131, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</code></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<section id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 135, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"detail-hold-placeholder\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-hold-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 137, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-swap=\"outerHTML\"></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -553,138 +588,138 @@ func RunSpecDiffs(d RunPageData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var28 == nil {
|
||||
templ_7745c5c3_Var28 = templ.NopComponent
|
||||
templ_7745c5c3_Var31 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var31 == nil {
|
||||
templ_7745c5c3_Var31 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<section id=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<section id=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 147, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 148, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" class=\"detail-section detail-diffs\" sse-swap=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" class=\"detail-section detail-diffs\" sse-swap=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("detail-specdiffs-%d", d.Run.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 149, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 150, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-swap=\"outerHTML\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" hx-swap=\"outerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(d.SpecDiffs) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<details")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<details")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if hasCriticalDiff(d.SpecDiffs) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " open")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " open")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "><summary><h2>Spec diffs (")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "><summary><h2>Spec diffs (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 154, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 155, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ")</h2></summary><ul class=\"diff-list\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ")</h2></summary><ul class=\"diff-list\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, diff := range d.SpecDiffs {
|
||||
var templ_7745c5c3_Var32 = []any{"diff-row", "diff-" + diff.Severity}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var32...)
|
||||
var templ_7745c5c3_Var35 = []any{"diff-row", "diff-" + diff.Severity}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<li class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var32).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><div class=\"diff-field\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 158, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div><div class=\"diff-expected\">expected: <code>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 159, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code></div><div class=\"diff-actual\">actual: <code>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<li class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var35).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 160, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</code></div></li>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\"><div class=\"diff-field\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 159, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div><div class=\"diff-expected\">expected: <code>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 160, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</code></div><div class=\"diff-actual\">actual: <code>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/run_detail.templ`, Line: 161, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</code></div></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</ul></details>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</ul></details>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 63, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -112,7 +112,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -125,7 +125,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 65, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -147,7 +147,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -160,7 +160,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 68, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -173,7 +173,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 69, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -186,7 +186,7 @@ func SubStepRow(ss model.SubStep) templ.Component {
|
||||
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}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `substep_row.templ`, Line: 70, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
Reference in New Issue
Block a user