Host detail v2: full pipeline + per-stage logs + WoL diagnostics
CI / Lint + build + test (push) Has been cancelled
CI / Lint + build + test (push) Has been cancelled
Pipeline now always renders all 13 nodes (3 pre-stage + 9 stage +
Completed), synthesising ghosts from run state when stage rows
aren't seeded yet. Makes a WaitingWoL host show the full timeline
ahead of it instead of just 4 dots.
Agent tags each log line with its stage; logs.Hub fans out to both
log-{runID} and log-{runID}-{stage} SSE events so the detail page
can show per-stage tabs with a pure-CSS radio-sibling switch. Flat
run log prepends [stage] so grep still works.
Dispatcher writes picked/sent-WoL/heartbeat lines into the per-run
log — the operator opens the detail page, sees WaitingWoL stuck,
and reads exactly what the dispatcher did and why nothing's
progressing, instead of having to tail journalctl on the LXC.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,7 @@ type HeartbeatResponse struct {
|
|||||||
type LogLine struct {
|
type LogLine struct {
|
||||||
TS string `json:"ts,omitempty"`
|
TS string `json:"ts,omitempty"`
|
||||||
Level string `json:"level,omitempty"`
|
Level string `json:"level,omitempty"`
|
||||||
|
Stage string `json:"stage,omitempty"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -120,6 +120,8 @@ func Run(ctx context.Context, p *bootstate.Params) error {
|
|||||||
// (the orchestrator persists it as an artifact). Every other stage
|
// (the orchestrator persists it as an artifact). Every other stage
|
||||||
// returns a tests.Outcome which postResult marshals generically.
|
// returns a tests.Outcome which postResult marshals generically.
|
||||||
func runStage(ctx context.Context, stage string, claim *ClaimResponse, fwd *logForwarder, c *Client, ovr overrideFlags) stageOutcome {
|
func runStage(ctx context.Context, stage string, claim *ClaimResponse, fwd *logForwarder, c *Client, ovr overrideFlags) stageOutcome {
|
||||||
|
fwd.SetStage(stage)
|
||||||
|
defer fwd.ClearStage()
|
||||||
deps := newDeps(ctx, c, fwd, ovr, claim)
|
deps := newDeps(ctx, c, fwd, ovr, claim)
|
||||||
switch stage {
|
switch stage {
|
||||||
case "Inventory":
|
case "Inventory":
|
||||||
@@ -436,6 +438,7 @@ type logForwarder struct {
|
|||||||
c *Client
|
c *Client
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buf []LogLine
|
buf []LogLine
|
||||||
|
stage string // set via SetStage; empties via ClearStage
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
@@ -467,7 +470,7 @@ func (f *logForwarder) push(level, text string) {
|
|||||||
stamp := time.Now().UTC().Format(time.RFC3339Nano)
|
stamp := time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
log.Printf("[%s] %s", level, text)
|
log.Printf("[%s] %s", level, text)
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.buf = append(f.buf, LogLine{TS: stamp, Level: level, Text: text})
|
f.buf = append(f.buf, LogLine{TS: stamp, Level: level, Stage: f.stage, Text: text})
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +478,23 @@ func (f *logForwarder) info(s string) { f.push("info", s) }
|
|||||||
func (f *logForwarder) warn(s string) { f.push("warn", s) }
|
func (f *logForwarder) warn(s string) { f.push("warn", s) }
|
||||||
func (f *logForwarder) error(s string) { f.push("error", s) }
|
func (f *logForwarder) error(s string) { f.push("error", s) }
|
||||||
|
|
||||||
|
// SetStage tags subsequent log lines with a stage name so the orchestrator
|
||||||
|
// can fan them out on a per-stage SSE event. Safe to call concurrently
|
||||||
|
// with push — we take the same mutex.
|
||||||
|
func (f *logForwarder) SetStage(stage string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.stage = stage
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearStage reverts to untagged (framing-level) logging. Defer this
|
||||||
|
// on entry to runStage so hold/override paths don't leak stage context.
|
||||||
|
func (f *logForwarder) ClearStage() {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.stage = ""
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (f *logForwarder) flush() {
|
func (f *logForwarder) flush() {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
if len(f.buf) == 0 {
|
if len(f.buf) == 0 {
|
||||||
|
|||||||
+2
-1
@@ -103,6 +103,7 @@ func main() {
|
|||||||
SpecDiffs: specDiffStore,
|
SpecDiffs: specDiffStore,
|
||||||
Artifacts: artifactStore,
|
Artifacts: artifactStore,
|
||||||
EventHub: hub,
|
EventHub: hub,
|
||||||
|
Logs: logHub,
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
Tiles: tiles,
|
Tiles: tiles,
|
||||||
PublicURL: cfg.Server.PublicURL,
|
PublicURL: cfg.Server.PublicURL,
|
||||||
@@ -126,7 +127,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
agentAPI.LiveKernelURL, agentAPI.LiveInitrdURL = pxe.BuildLiveURLs(cfg.PXE.OrchestratorURL)
|
agentAPI.LiveKernelURL, agentAPI.LiveInitrdURL = pxe.BuildLiveURLs(cfg.PXE.OrchestratorURL)
|
||||||
|
|
||||||
dispatcher := orchestrator.NewDispatcher(cfg.Dispatcher.MaxConcurrentRuns, runStore, hostStore, runner)
|
dispatcher := orchestrator.NewDispatcher(cfg.Dispatcher.MaxConcurrentRuns, runStore, hostStore, runner, logHub)
|
||||||
iperfSup := orchestrator.NewIperfSupervisor(cfg.Network.IperfPort)
|
iperfSup := orchestrator.NewIperfSupervisor(cfg.Network.IperfPort)
|
||||||
|
|
||||||
janitorSvc := janitor.New(janitor.Config{
|
janitorSvc := janitor.New(janitor.Config{
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ func (a *Agent) Claim(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("agent claimed: run=%d agent_ip=%s", runID, agentIP)
|
log.Printf("agent claimed: run=%d agent_ip=%s", runID, agentIP)
|
||||||
|
if a.Logs != nil {
|
||||||
|
if w, err := a.Logs.WriterFor(runID); err == nil {
|
||||||
|
w.Append(logs.Line{Level: "info", Text: fmt.Sprintf("agent claimed from %s — entering Inventory", agentIP)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stage-driven agent needs a bit of per-run config: the device
|
// Stage-driven agent needs a bit of per-run config: the device
|
||||||
// allowlist (serial + expected size) for Storage, and the iperf3
|
// allowlist (serial + expected size) for Storage, and the iperf3
|
||||||
@@ -331,6 +336,7 @@ type LogBatch struct {
|
|||||||
type LogLine struct {
|
type LogLine struct {
|
||||||
TS string `json:"ts,omitempty"` // RFC3339Nano; server clock used if empty
|
TS string `json:"ts,omitempty"` // RFC3339Nano; server clock used if empty
|
||||||
Level string `json:"level,omitempty"` // info|warn|error|debug
|
Level string `json:"level,omitempty"` // info|warn|error|debug
|
||||||
|
Stage string `json:"stage,omitempty"` // optional stage tag for per-stage log fan-out
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +362,7 @@ func (a *Agent) Log(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
for _, l := range batch.Lines {
|
for _, l := range batch.Lines {
|
||||||
ts, _ := time.Parse(time.RFC3339Nano, l.TS)
|
ts, _ := time.Parse(time.RFC3339Nano, l.TS)
|
||||||
writer.Append(logs.Line{TS: ts, Level: l.Level, Text: l.Text})
|
writer.Append(logs.Line{TS: ts, Level: l.Level, Stage: l.Stage, Text: l.Text})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "written": len(batch.Lines)})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "written": len(batch.Lines)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,53 @@ func TestHostDetail_NeverRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHostDetail_LogTabsRendered: when a run exists, the detail page
|
||||||
|
// emits the log-tabs scaffold with one radio per stage + an "All" tab
|
||||||
|
// checked by default. CSS sibling selectors drive visibility — no JS.
|
||||||
|
func TestHostDetail_LogTabsRendered(t *testing.T) {
|
||||||
|
ui, hosts, runs := setupDetail(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, err := hosts.Create(ctx, model.Host{
|
||||||
|
Name: "tabs-host",
|
||||||
|
MAC: "aa:bb:cc:dd:ee:40",
|
||||||
|
WoLBroadcastIP: "10.0.0.255",
|
||||||
|
WoLPort: 9,
|
||||||
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create host: %v", err)
|
||||||
|
}
|
||||||
|
runID, err := runs.Create(ctx, id, "cafef00d")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
ui.HostDetail(rr, detailReq(id))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
|
||||||
|
// All tab: the default-checked radio, plus its pane.
|
||||||
|
wantAllID := fmt.Sprintf(`id="log-tab-%d-all"`, runID)
|
||||||
|
if !strings.Contains(body, wantAllID) {
|
||||||
|
t.Fatalf("body missing All tab radio %s", wantAllID)
|
||||||
|
}
|
||||||
|
// Per-stage tabs: every entry in DefaultStageOrder must have its own
|
||||||
|
// radio + pane so tabs switch purely via sibling CSS.
|
||||||
|
for _, s := range store.DefaultStageOrder {
|
||||||
|
wantRadio := fmt.Sprintf(`id="log-tab-%d-%s"`, runID, s)
|
||||||
|
if !strings.Contains(body, wantRadio) {
|
||||||
|
t.Fatalf("body missing stage tab radio %s", wantRadio)
|
||||||
|
}
|
||||||
|
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
|
||||||
|
if !strings.Contains(body, wantPane) {
|
||||||
|
t.Fatalf("body missing stage pane %s", wantPane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHostDetail_UnknownID(t *testing.T) {
|
func TestHostDetail_UnknownID(t *testing.T) {
|
||||||
ui, _, _ := setupDetail(t)
|
ui, _, _ := setupDetail(t)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"vetting/internal/events"
|
"vetting/internal/events"
|
||||||
|
"vetting/internal/logs"
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
"vetting/internal/orchestrator"
|
"vetting/internal/orchestrator"
|
||||||
"vetting/internal/store"
|
"vetting/internal/store"
|
||||||
@@ -30,6 +31,7 @@ type UI struct {
|
|||||||
SpecDiffs *store.SpecDiffs
|
SpecDiffs *store.SpecDiffs
|
||||||
Artifacts *store.Artifacts
|
Artifacts *store.Artifacts
|
||||||
EventHub *events.Hub
|
EventHub *events.Hub
|
||||||
|
Logs *logs.Hub
|
||||||
Runner *orchestrator.Runner
|
Runner *orchestrator.Runner
|
||||||
Tiles *TileEnricher
|
Tiles *TileEnricher
|
||||||
PublicURL string // user-visible base URL baked into the quick-register one-liner
|
PublicURL string // user-visible base URL baked into the quick-register one-liner
|
||||||
@@ -113,10 +115,15 @@ func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
t := u.Tiles.Build(r.Context(), *host, latest)
|
t := u.Tiles.Build(r.Context(), *host, latest)
|
||||||
|
replay := ""
|
||||||
|
if latest != nil && u.Logs != nil {
|
||||||
|
replay = u.Logs.Replay(latest.ID)
|
||||||
|
}
|
||||||
data := templates.HostDetailData{
|
data := templates.HostDetailData{
|
||||||
Tile: t,
|
Tile: t,
|
||||||
Stages: stages,
|
Stages: stages,
|
||||||
SpecDiffs: diffs,
|
SpecDiffs: diffs,
|
||||||
|
LogReplay: replay,
|
||||||
}
|
}
|
||||||
_ = templates.HostDetail(data).Render(r.Context(), w)
|
_ = templates.HostDetail(data).Render(r.Context(), w)
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-6
@@ -21,6 +21,7 @@ import (
|
|||||||
type Line struct {
|
type Line struct {
|
||||||
TS time.Time
|
TS time.Time
|
||||||
Level string // info|warn|error|debug
|
Level string // info|warn|error|debug
|
||||||
|
Stage string // optional — one of store.DefaultStageOrder; empty = orchestrator/agent framing
|
||||||
Text string
|
Text string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +86,54 @@ func (h *Hub) PathFor(runID int64) string {
|
|||||||
return filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID))
|
return filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replay reads the on-disk log for a run and returns one
|
||||||
|
// <div class="log-line"> fragment per line, suitable for inlining into
|
||||||
|
// the "All" log pane on initial page load. Missing file → empty string;
|
||||||
|
// the pane just stays empty until live events arrive. Does not subscribe
|
||||||
|
// to the SSE hub — callers are expected to pair this with a live
|
||||||
|
// sse-swap target on the same element.
|
||||||
|
func (h *Hub) Replay(runID int64) string {
|
||||||
|
path := h.PathFor(runID)
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var out strings.Builder
|
||||||
|
for _, raw := range strings.Split(string(b), "\n") {
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Format from Append: "<RFC3339Nano> <LEVEL> <text>"
|
||||||
|
// where LEVEL is right-padded to width 5 (e.g. " INFO",
|
||||||
|
// "ERROR"). TrimLeft the pad before splitting off the level.
|
||||||
|
tsEnd := strings.IndexByte(raw, ' ')
|
||||||
|
if tsEnd < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, raw[:tsEnd])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := strings.TrimLeft(raw[tsEnd+1:], " ")
|
||||||
|
lvEnd := strings.IndexByte(rest, ' ')
|
||||||
|
if lvEnd < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
level := strings.ToLower(rest[:lvEnd])
|
||||||
|
text := rest[lvEnd+1:]
|
||||||
|
// Disk format prepends "[stage] " to text when stage was set.
|
||||||
|
stage := ""
|
||||||
|
if strings.HasPrefix(text, "[") {
|
||||||
|
if end := strings.Index(text, "] "); end > 1 {
|
||||||
|
stage = text[1:end]
|
||||||
|
text = text[end+2:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.WriteString(renderLogSSE(Line{TS: ts, Level: level, Stage: stage, Text: text}))
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
// Append writes a line to disk and publishes an SSE event. Failures
|
// Append writes a line to disk and publishes an SSE event. Failures
|
||||||
// on disk log but don't block the SSE fan-out — the operator can still
|
// on disk log but don't block the SSE fan-out — the operator can still
|
||||||
// see the live tail even if disk IO is degraded.
|
// see the live tail even if disk IO is degraded.
|
||||||
@@ -97,15 +146,26 @@ func (w *Writer) Append(line Line) {
|
|||||||
if line.Level == "" {
|
if line.Level == "" {
|
||||||
line.Level = "info"
|
line.Level = "info"
|
||||||
}
|
}
|
||||||
stamped := fmt.Sprintf("%s %5s %s\n", line.TS.Format(time.RFC3339Nano), strings.ToUpper(line.Level), line.Text)
|
diskText := line.Text
|
||||||
|
if line.Stage != "" {
|
||||||
|
diskText = "[" + line.Stage + "] " + diskText
|
||||||
|
}
|
||||||
|
stamped := fmt.Sprintf("%s %5s %s\n", line.TS.Format(time.RFC3339Nano), strings.ToUpper(line.Level), diskText)
|
||||||
if _, err := w.f.WriteString(stamped); err != nil {
|
if _, err := w.f.WriteString(stamped); err != nil {
|
||||||
log.Printf("logs: write run-%d: %v", w.runID, err)
|
log.Printf("logs: write run-%d: %v", w.runID, err)
|
||||||
}
|
}
|
||||||
if w.hub != nil {
|
if w.hub != nil {
|
||||||
|
payload := renderLogSSE(line)
|
||||||
w.hub.Publish(events.Event{
|
w.hub.Publish(events.Event{
|
||||||
Name: fmt.Sprintf("log-%d", w.runID),
|
Name: fmt.Sprintf("log-%d", w.runID),
|
||||||
Payload: renderLogSSE(line),
|
Payload: payload,
|
||||||
})
|
})
|
||||||
|
if line.Stage != "" {
|
||||||
|
w.hub.Publish(events.Event{
|
||||||
|
Name: fmt.Sprintf("log-%d-%s", w.runID, line.Stage),
|
||||||
|
Payload: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,15 +180,22 @@ func (w *Writer) Close() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderLogSSE returns an HTMX-compatible fragment. The tile contains
|
// renderLogSSE returns an HTMX-compatible fragment. The detail-page
|
||||||
// a <div id="log-N" hx-swap-oob="beforeend">: each event appends one
|
// panes contain <div id="log-N-..." hx-swap="beforeend">: each event
|
||||||
// <div class="log-line log-LEVEL"> to it.
|
// appends one <div class="log-line log-LEVEL"> to them. Stage, if set,
|
||||||
|
// is rendered as a dim prefix so the "All" pane stays disambiguable
|
||||||
|
// even with multiple stages interleaved.
|
||||||
func renderLogSSE(l Line) string {
|
func renderLogSSE(l Line) string {
|
||||||
level := strings.ToLower(l.Level)
|
level := strings.ToLower(l.Level)
|
||||||
|
stagePrefix := ""
|
||||||
|
if l.Stage != "" {
|
||||||
|
stagePrefix = fmt.Sprintf(`<span class="log-stage">[%s]</span> `, html.EscapeString(l.Stage))
|
||||||
|
}
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
`<div class="log-line log-%s">%s %s</div>`,
|
`<div class="log-line log-%s">%s %s%s</div>`,
|
||||||
html.EscapeString(level),
|
html.EscapeString(level),
|
||||||
html.EscapeString(l.TS.Format("15:04:05")),
|
html.EscapeString(l.TS.Format("15:04:05")),
|
||||||
|
stagePrefix,
|
||||||
html.EscapeString(l.Text),
|
html.EscapeString(l.Text),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,88 @@ func TestAppendFansOutToSSE(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAppendStagePublishesBothEvents: a line tagged with a stage must
|
||||||
|
// fan out to BOTH the all-pane event (log-<runID>) AND the stage-pane
|
||||||
|
// event (log-<runID>-<stage>) so the detail page's per-stage tabs see
|
||||||
|
// their own slice. Disk format prepends "[stage] " so the flat log
|
||||||
|
// remains greppable.
|
||||||
|
func TestAppendStagePublishesBothEvents(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
hub := events.NewHub()
|
||||||
|
lh, err := logs.NewHub(dir, hub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHub: %v", err)
|
||||||
|
}
|
||||||
|
defer lh.Close()
|
||||||
|
|
||||||
|
_, ch, cancel := hub.Subscribe()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
w, err := lh.WriterFor(42)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriterFor: %v", err)
|
||||||
|
}
|
||||||
|
w.Append(logs.Line{Level: "info", Stage: "SMART", Text: "reading attributes"})
|
||||||
|
|
||||||
|
got := collect(ch, 4, 500*time.Millisecond)
|
||||||
|
names := map[string]int{}
|
||||||
|
for _, ev := range got {
|
||||||
|
if strings.HasPrefix(ev.Name, "log-") {
|
||||||
|
names[ev.Name]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if names["log-42"] != 1 {
|
||||||
|
t.Fatalf("expected 1 event on log-42, got %d (names=%+v)", names["log-42"], names)
|
||||||
|
}
|
||||||
|
if names["log-42-SMART"] != 1 {
|
||||||
|
t.Fatalf("expected 1 event on log-42-SMART, got %d (names=%+v)", names["log-42-SMART"], names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk: stage prepended so flat log is still useful.
|
||||||
|
body, err := os.ReadFile(filepath.Join(dir, "run-42.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read log file: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "[SMART] reading attributes") {
|
||||||
|
t.Fatalf("disk log missing stage prefix: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplay re-parses a file written by Append and emits the same SSE
|
||||||
|
// fragments — detail-page uses this to seed the All pane on reload of
|
||||||
|
// an in-flight run.
|
||||||
|
func TestReplay(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
hub := events.NewHub()
|
||||||
|
lh, err := logs.NewHub(dir, hub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHub: %v", err)
|
||||||
|
}
|
||||||
|
defer lh.Close()
|
||||||
|
|
||||||
|
w, err := lh.WriterFor(99)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WriterFor: %v", err)
|
||||||
|
}
|
||||||
|
w.Append(logs.Line{Level: "info", Text: "dispatcher: picked"})
|
||||||
|
w.Append(logs.Line{Level: "info", Stage: "SMART", Text: "smartctl /dev/sda"})
|
||||||
|
|
||||||
|
replay := lh.Replay(99)
|
||||||
|
if !strings.Contains(replay, "dispatcher: picked") {
|
||||||
|
t.Fatalf("replay missing untagged line: %q", replay)
|
||||||
|
}
|
||||||
|
if !strings.Contains(replay, "smartctl /dev/sda") {
|
||||||
|
t.Fatalf("replay missing tagged line: %q", replay)
|
||||||
|
}
|
||||||
|
if !strings.Contains(replay, `class="log-stage"`) {
|
||||||
|
t.Fatalf("replay should render stage badge for tagged line: %q", replay)
|
||||||
|
}
|
||||||
|
// Missing file → empty string, no panic.
|
||||||
|
if got := lh.Replay(12345); got != "" {
|
||||||
|
t.Fatalf("replay of unknown run = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestWriterForIsCached verifies a second call returns the same Writer
|
// TestWriterForIsCached verifies a second call returns the same Writer
|
||||||
// — otherwise parallel /log POSTs would race on file opens and possibly
|
// — otherwise parallel /log POSTs would race on file opens and possibly
|
||||||
// stomp on in-flight writes.
|
// stomp on in-flight writes.
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package orchestrator
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"vetting/internal/logs"
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
"vetting/internal/store"
|
"vetting/internal/store"
|
||||||
)
|
)
|
||||||
@@ -12,6 +15,10 @@ import (
|
|||||||
// Dispatcher picks Queued runs off the DB and drives them through
|
// Dispatcher picks Queued runs off the DB and drives them through
|
||||||
// WaitingWoL (sending a WoL packet). Concurrency is capped at Max.
|
// WaitingWoL (sending a WoL packet). Concurrency is capped at Max.
|
||||||
//
|
//
|
||||||
|
// Pre-stage log lines (picked, WoL-sent, heartbeat, agent-claimed)
|
||||||
|
// are written into the per-run log via Logs so the detail page's
|
||||||
|
// log pane can show what's happening before the agent is alive.
|
||||||
|
//
|
||||||
// For Phase 2 the dispatcher's job ends at WaitingWoL; further
|
// For Phase 2 the dispatcher's job ends at WaitingWoL; further
|
||||||
// transitions are driven by iPXE and agent callbacks. Phase 4+ will
|
// transitions are driven by iPXE and agent callbacks. Phase 4+ will
|
||||||
// return here and shepherd each run through stage execution.
|
// return here and shepherd each run through stage execution.
|
||||||
@@ -20,12 +27,19 @@ type Dispatcher struct {
|
|||||||
Runs *store.Runs
|
Runs *store.Runs
|
||||||
Hosts *store.Hosts
|
Hosts *store.Hosts
|
||||||
Runner *Runner
|
Runner *Runner
|
||||||
|
Logs *logs.Hub
|
||||||
|
|
||||||
active chan struct{}
|
active chan struct{}
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
|
|
||||||
|
// heartbeat tracks the last time we emitted a "still waiting"
|
||||||
|
// line for a given run, so the ticker doesn't spam the log.
|
||||||
|
hbMu sync.Mutex
|
||||||
|
lastBeat map[int64]time.Time
|
||||||
|
beatEvery time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDispatcher(max int, runs *store.Runs, hosts *store.Hosts, runner *Runner) *Dispatcher {
|
func NewDispatcher(max int, runs *store.Runs, hosts *store.Hosts, runner *Runner, logHub *logs.Hub) *Dispatcher {
|
||||||
if max < 1 {
|
if max < 1 {
|
||||||
max = 1
|
max = 1
|
||||||
}
|
}
|
||||||
@@ -34,8 +48,11 @@ func NewDispatcher(max int, runs *store.Runs, hosts *store.Hosts, runner *Runner
|
|||||||
Runs: runs,
|
Runs: runs,
|
||||||
Hosts: hosts,
|
Hosts: hosts,
|
||||||
Runner: runner,
|
Runner: runner,
|
||||||
|
Logs: logHub,
|
||||||
active: make(chan struct{}, max),
|
active: make(chan struct{}, max),
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
|
lastBeat: map[int64]time.Time{},
|
||||||
|
beatEvery: 30 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +75,7 @@ func (d *Dispatcher) loop(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
d.pickNext(ctx)
|
d.pickNext(ctx)
|
||||||
|
d.heartbeatWaiting(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,19 +124,93 @@ func (d *Dispatcher) pickNext(ctx context.Context) {
|
|||||||
log.Printf("dispatcher: get host %d: %v", queued.HostID, err)
|
log.Printf("dispatcher: get host %d: %v", queued.HostID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
d.runLog(queued.ID, "info", fmt.Sprintf("dispatcher: picked run for host %s (mac=%s wol=%s:%d)",
|
||||||
|
host.Name, host.MAC, host.WoLBroadcastIP, host.WoLPort))
|
||||||
if _, err := d.Runner.Transition(ctx, queued.ID, TriggerDispatched); err != nil {
|
if _, err := d.Runner.Transition(ctx, queued.ID, TriggerDispatched); err != nil {
|
||||||
log.Printf("dispatcher: transition run %d: %v", queued.ID, err)
|
log.Printf("dispatcher: transition run %d: %v", queued.ID, err)
|
||||||
|
d.runLog(queued.ID, "error", fmt.Sprintf("dispatcher: transition to WaitingWoL failed: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := SendWoL(host.MAC, host.WoLBroadcastIP, host.WoLPort); err != nil {
|
if err := SendWoL(host.MAC, host.WoLBroadcastIP, host.WoLPort); err != nil {
|
||||||
log.Printf("dispatcher: WoL run %d host %s: %v", queued.ID, host.Name, err)
|
log.Printf("dispatcher: WoL run %d host %s: %v", queued.ID, host.Name, err)
|
||||||
|
d.runLog(queued.ID, "error", fmt.Sprintf("dispatcher: WoL send failed: %v — check broadcast %s:%d is reachable",
|
||||||
|
err, host.WoLBroadcastIP, host.WoLPort))
|
||||||
// Stay in WaitingWoL; operator can retry or investigate.
|
// Stay in WaitingWoL; operator can retry or investigate.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("dispatcher: WoL sent for run %d (host=%s mac=%s)", queued.ID, host.Name, host.MAC)
|
log.Printf("dispatcher: WoL sent for run %d (host=%s mac=%s)", queued.ID, host.Name, host.MAC)
|
||||||
|
d.runLog(queued.ID, "info", fmt.Sprintf("dispatcher: sent WoL packet to %s via %s:%d — waiting for agent claim",
|
||||||
|
host.MAC, host.WoLBroadcastIP, host.WoLPort))
|
||||||
|
|
||||||
|
// Prime the heartbeat so the first "still waiting" fires 30s after
|
||||||
|
// dispatch, not immediately.
|
||||||
|
d.hbMu.Lock()
|
||||||
|
d.lastBeat[queued.ID] = time.Now()
|
||||||
|
d.hbMu.Unlock()
|
||||||
|
|
||||||
// Slot stays reserved until the run leaves active (Phase 4+).
|
// Slot stays reserved until the run leaves active (Phase 4+).
|
||||||
// Phase 2 lets the loop observe inFlight via DB state.
|
// Phase 2 lets the loop observe inFlight via DB state.
|
||||||
released = true
|
released = true
|
||||||
<-d.active
|
<-d.active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// heartbeatWaiting emits a "still waiting" log line every beatEvery for
|
||||||
|
// each run still sitting in WaitingWoL. Helps the operator spot hangs
|
||||||
|
// without having to tail journalctl on the LXC.
|
||||||
|
func (d *Dispatcher) heartbeatWaiting(ctx context.Context) {
|
||||||
|
if d.Logs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runs, err := d.Runs.Active(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
d.hbMu.Lock()
|
||||||
|
defer d.hbMu.Unlock()
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for i := range runs {
|
||||||
|
r := &runs[i]
|
||||||
|
seen[r.ID] = true
|
||||||
|
if r.State != model.StateWaitingWoL {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last, ok := d.lastBeat[r.ID]
|
||||||
|
if !ok {
|
||||||
|
// Run already in WaitingWoL from a previous process lifetime
|
||||||
|
// — prime so we don't spam immediately.
|
||||||
|
d.lastBeat[r.ID] = now
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if now.Sub(last) < d.beatEvery {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
elapsed := now.Sub(r.StartedAt).Truncate(time.Second)
|
||||||
|
d.runLog(r.ID, "info", fmt.Sprintf(
|
||||||
|
"still waiting for agent claim (%s) — check BIOS WoL, pxe.enabled, and live-image presence",
|
||||||
|
elapsed))
|
||||||
|
d.lastBeat[r.ID] = now
|
||||||
|
}
|
||||||
|
// Garbage-collect entries for runs that have left WaitingWoL.
|
||||||
|
for id := range d.lastBeat {
|
||||||
|
if !seen[id] {
|
||||||
|
delete(d.lastBeat, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLog writes a single line into the per-run log. Safe to call with a
|
||||||
|
// nil hub (tests construct Dispatcher directly) — it degrades to a
|
||||||
|
// stderr log line so nothing silently disappears.
|
||||||
|
func (d *Dispatcher) runLog(runID int64, level, text string) {
|
||||||
|
if d.Logs == nil {
|
||||||
|
log.Printf("run-%d %s: %s", runID, level, text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, err := d.Logs.WriterFor(runID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("dispatcher: open log for run %d: %v", runID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Append(logs.Line{Level: level, Text: text})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"vetting/internal/events"
|
||||||
|
"vetting/internal/logs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDispatcher_RunLogWritesToHub verifies the plumbing between the
|
||||||
|
// dispatcher and the per-run log hub: runLog must persist to the on-disk
|
||||||
|
// file so the detail page's replay + SSE fan-out see the same
|
||||||
|
// pre-stage diagnostics (picked / sent WoL / heartbeat).
|
||||||
|
func TestDispatcher_RunLogWritesToHub(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ev := events.NewHub()
|
||||||
|
lh, err := logs.NewHub(dir, ev)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHub: %v", err)
|
||||||
|
}
|
||||||
|
defer lh.Close()
|
||||||
|
|
||||||
|
d := &Dispatcher{Logs: lh}
|
||||||
|
d.runLog(7, "info", "dispatcher: sent WoL packet to aa:bb:cc:dd:ee:ff via 10.0.0.255:9")
|
||||||
|
|
||||||
|
body, err := os.ReadFile(filepath.Join(dir, "run-7.log"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read run log: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "dispatcher: sent WoL packet") {
|
||||||
|
t.Fatalf("run log missing dispatcher line: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "INFO") {
|
||||||
|
t.Fatalf("run log missing level: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDispatcher_RunLogNilHubDoesNotPanic: tests construct Dispatcher
|
||||||
|
// directly without a hub. runLog must degrade to stderr rather than
|
||||||
|
// panicking so the dispatcher loop stays alive.
|
||||||
|
func TestDispatcher_RunLogNilHubDoesNotPanic(t *testing.T) {
|
||||||
|
d := &Dispatcher{}
|
||||||
|
d.runLog(1, "info", "fallback path")
|
||||||
|
}
|
||||||
+106
-17
@@ -366,6 +366,79 @@ body.bare main { max-width: none; }
|
|||||||
.detail-log .log-warn { color: var(--warn); }
|
.detail-log .log-warn { color: var(--warn); }
|
||||||
.detail-log .log-error { color: var(--danger); }
|
.detail-log .log-error { color: var(--danger); }
|
||||||
|
|
||||||
|
/* ===== Log tabs (CSS-only radio switch) ===== */
|
||||||
|
/* Radios are visually hidden but still functional: checked state is read
|
||||||
|
by sibling selectors below to flip the active label + pane. */
|
||||||
|
.log-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #0b0d12;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.log-tab-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.log-tab-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
user-select: none;
|
||||||
|
background: transparent;
|
||||||
|
transition: background .12s ease, color .12s ease;
|
||||||
|
}
|
||||||
|
.log-tab-label:hover { background: rgba(255,255,255,.03); color: var(--text); }
|
||||||
|
/* Active tab: coloured bar + filled background. */
|
||||||
|
.log-tab-input:checked + .log-tab-label {
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: inset 0 -2px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
.log-pane {
|
||||||
|
display: none;
|
||||||
|
flex-basis: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
order: 99; /* keep panes below the tab labels in flex order */
|
||||||
|
}
|
||||||
|
.log-pane .log-line { white-space: pre-wrap; }
|
||||||
|
.log-pane .log-warn { color: var(--warn); }
|
||||||
|
.log-pane .log-error { color: var(--danger); }
|
||||||
|
.log-pane .log-stage { color: var(--text-dim); opacity: .75; margin-right: 4px; }
|
||||||
|
.log-pane:empty::before { content: "(no log output yet)"; color: var(--text-dim); opacity: .5; }
|
||||||
|
|
||||||
|
/* Sibling-selector cascade: .log-tab-all:checked flips .log-pane-all,
|
||||||
|
.log-tab-Inventory:checked flips .log-pane-Inventory, etc. The radios
|
||||||
|
live as siblings of the panes inside .log-tabs, so ~ works. */
|
||||||
|
.log-tab-all:checked ~ .log-pane-all { display: flex; }
|
||||||
|
.log-tab-Inventory:checked ~ .log-pane-Inventory { display: flex; }
|
||||||
|
.log-tab-SpecValidate:checked ~ .log-pane-SpecValidate { display: flex; }
|
||||||
|
.log-tab-SMART:checked ~ .log-pane-SMART { display: flex; }
|
||||||
|
.log-tab-CPUStress:checked ~ .log-pane-CPUStress { display: flex; }
|
||||||
|
.log-tab-Storage:checked ~ .log-pane-Storage { display: flex; }
|
||||||
|
.log-tab-Network:checked ~ .log-pane-Network { display: flex; }
|
||||||
|
.log-tab-GPU:checked ~ .log-pane-GPU { display: flex; }
|
||||||
|
.log-tab-PSU:checked ~ .log-pane-PSU { display: flex; }
|
||||||
|
.log-tab-Reporting:checked ~ .log-pane-Reporting { display: flex; }
|
||||||
|
|
||||||
.diff-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
.diff-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.diff-row {
|
.diff-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -399,57 +472,73 @@ body.bare main { max-width: none; }
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Pipeline timeline ===== */
|
/* ===== Pipeline timeline =====
|
||||||
|
13 nodes (3 pre-stage + 9 stage + Completed). flex:1 on every node
|
||||||
|
so they share the full width evenly; overflow-x:auto only kicks in
|
||||||
|
on very narrow viewports. */
|
||||||
.pipeline {
|
.pipeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
width: 100%;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 12px 4px 6px;
|
padding: 28px 12px 16px;
|
||||||
|
background: #0b0d12;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.stage-node {
|
.stage-node {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
min-width: 82px;
|
flex: 1 1 0;
|
||||||
padding: 0 6px;
|
min-width: 72px;
|
||||||
flex-shrink: 0;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
.stage-dot {
|
.stage-dot {
|
||||||
width: 22px;
|
width: 28px;
|
||||||
height: 22px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
background: var(--bg-elev-2);
|
background: var(--bg-elev-2);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
transition: transform .12s ease, box-shadow .12s ease;
|
||||||
}
|
}
|
||||||
|
.stage-node:hover .stage-dot { transform: scale(1.12); }
|
||||||
.stage-dot-passed { background: var(--success); border-color: var(--success); color: #0b0d12; }
|
.stage-dot-passed { background: var(--success); border-color: var(--success); color: #0b0d12; }
|
||||||
.stage-dot-running { background: var(--accent-strong); border-color: var(--accent); color: #fff; animation: pulse 1.2s ease-in-out infinite; }
|
.stage-dot-running { background: var(--accent-strong); border-color: var(--accent); color: #fff; animation: pulse 1.2s ease-in-out infinite; }
|
||||||
.stage-dot-failed { background: var(--danger); border-color: var(--danger); color: #fff; }
|
.stage-dot-failed { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
.stage-dot-skipped { background: transparent; border-color: var(--border); color: var(--text-dim); opacity: .45; }
|
.stage-dot-skipped { background: transparent; border-color: var(--border); color: var(--text-dim); opacity: .45; }
|
||||||
.stage-dot-pending { background: transparent; border-color: var(--border); color: transparent; }
|
.stage-dot-pending { background: transparent; border-color: var(--border); color: transparent; }
|
||||||
|
|
||||||
.stage-name { font-size: 11px; color: var(--text-dim); text-align: center; }
|
.stage-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
.stage-node-passed .stage-name { color: var(--text); }
|
.stage-node-passed .stage-name { color: var(--text); }
|
||||||
.stage-node-running .stage-name { color: var(--accent); }
|
.stage-node-running .stage-name { color: var(--accent); font-weight: 600; }
|
||||||
.stage-node-failed .stage-name { color: var(--danger); }
|
.stage-node-failed .stage-name { color: var(--danger); font-weight: 600; }
|
||||||
.stage-node-skipped .stage-name { opacity: .5; }
|
.stage-node-skipped .stage-name { opacity: .5; }
|
||||||
.stage-duration { font-size: 10px; color: var(--text-dim); font-family: var(--mono); min-height: 12px; }
|
.stage-duration { font-size: 10px; color: var(--text-dim); font-family: var(--mono); min-height: 12px; }
|
||||||
|
|
||||||
.stage-connector {
|
.stage-connector {
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
min-width: 12px;
|
min-width: 8px;
|
||||||
height: 2px;
|
height: 3px;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
margin-top: -18px;
|
margin-top: -30px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
.stage-connector-passed { background: var(--success); }
|
.stage-connector-passed { background: var(--success); }
|
||||||
.stage-connector-running { background: linear-gradient(90deg, var(--success), var(--accent)); }
|
.stage-connector-running { background: linear-gradient(90deg, var(--success), var(--accent)); }
|
||||||
@@ -458,5 +547,5 @@ body.bare main { max-width: none; }
|
|||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); }
|
0%, 100% { box-shadow: 0 0 0 0 rgba(60,130,246,.55); }
|
||||||
50% { box-shadow: 0 0 0 6px rgba(60,130,246,0); }
|
50% { box-shadow: 0 0 0 8px rgba(60,130,246,0); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,20 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostDetailData is the full payload the detail handler hands to the
|
// HostDetailData is the full payload the detail handler hands to the
|
||||||
// HostDetail template. Tile carries host + latest-run enrichment (same
|
// HostDetail template. Tile carries host + latest-run enrichment (same
|
||||||
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
||||||
// and diff list.
|
// and diff list. LogReplay is the pre-rendered history fragment
|
||||||
|
// produced by logs.Hub.Replay on the initial page render so the operator
|
||||||
|
// sees prior output without waiting for a fresh SSE event.
|
||||||
type HostDetailData struct {
|
type HostDetailData struct {
|
||||||
Tile TileData
|
Tile TileData
|
||||||
Stages []model.Stage
|
Stages []model.Stage
|
||||||
SpecDiffs []model.SpecDiff
|
SpecDiffs []model.SpecDiff
|
||||||
|
LogReplay string
|
||||||
}
|
}
|
||||||
|
|
||||||
templ HostDetail(d HostDetailData) {
|
templ HostDetail(d HostDetailData) {
|
||||||
@@ -123,15 +127,7 @@ templ HostDetail(d HostDetailData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if d.Tile.Latest != nil {
|
if d.Tile.Latest != nil {
|
||||||
<section class="detail-section">
|
@LogTabs(d.Tile.Latest.ID, d.LogReplay)
|
||||||
<h2>Log</h2>
|
|
||||||
<div
|
|
||||||
class="detail-log"
|
|
||||||
id={ fmt.Sprintf("log-%d", d.Tile.Latest.ID) }
|
|
||||||
sse-swap={ fmt.Sprintf("log-%d", d.Tile.Latest.ID) }
|
|
||||||
hx-swap="beforeend"
|
|
||||||
></div>
|
|
||||||
</section>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<section class="detail-section detail-host-meta">
|
<section class="detail-section detail-host-meta">
|
||||||
@@ -163,3 +159,38 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
|
||||||
|
// Switching is pure CSS: hidden radio inputs drive sibling-selector
|
||||||
|
// visibility on the panes. Each pane carries its own sse-swap target so
|
||||||
|
// live events append only to the relevant pane. The All pane is seeded
|
||||||
|
// with replay HTML so reload on an in-flight run still shows history.
|
||||||
|
templ LogTabs(runID int64, replay string) {
|
||||||
|
<section class="detail-section log-section">
|
||||||
|
<h2>Log</h2>
|
||||||
|
<div class="log-tabs">
|
||||||
|
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-input log-tab-all" checked/>
|
||||||
|
<label for={ fmt.Sprintf("log-tab-%d-all", runID) } class="log-tab-label">All</label>
|
||||||
|
for _, s := range store.DefaultStageOrder {
|
||||||
|
<input type="radio" name={ fmt.Sprintf("log-tab-%d", runID) } id={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class={ "log-tab-input", "log-tab-" + s }/>
|
||||||
|
<label for={ fmt.Sprintf("log-tab-%d-%s", runID, s) } class="log-tab-label">{ s }</label>
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
class="log-pane log-pane-all"
|
||||||
|
id={ fmt.Sprintf("log-%d", runID) }
|
||||||
|
sse-swap={ fmt.Sprintf("log-%d", runID) }
|
||||||
|
hx-swap="beforeend show:bottom"
|
||||||
|
>
|
||||||
|
@templ.Raw(replay)
|
||||||
|
</div>
|
||||||
|
for _, s := range store.DefaultStageOrder {
|
||||||
|
<div
|
||||||
|
class={ "log-pane", "log-pane-" + s }
|
||||||
|
id={ fmt.Sprintf("log-%d-%s", runID, s) }
|
||||||
|
sse-swap={ fmt.Sprintf("log-%d-%s", runID, s) }
|
||||||
|
hx-swap="beforeend show:bottom"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,16 +12,20 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostDetailData is the full payload the detail handler hands to the
|
// HostDetailData is the full payload the detail handler hands to the
|
||||||
// HostDetail template. Tile carries host + latest-run enrichment (same
|
// HostDetail template. Tile carries host + latest-run enrichment (same
|
||||||
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
// shape the dashboard tile uses), Stages/SpecDiffs drive the pipeline
|
||||||
// and diff list.
|
// and diff list. LogReplay is the pre-rendered history fragment
|
||||||
|
// produced by logs.Hub.Replay on the initial page render so the operator
|
||||||
|
// sees prior output without waiting for a fresh SSE event.
|
||||||
type HostDetailData struct {
|
type HostDetailData struct {
|
||||||
Tile TileData
|
Tile TileData
|
||||||
Stages []model.Stage
|
Stages []model.Stage
|
||||||
SpecDiffs []model.SpecDiff
|
SpecDiffs []model.SpecDiff
|
||||||
|
LogReplay string
|
||||||
}
|
}
|
||||||
|
|
||||||
func HostDetail(d HostDetailData) templ.Component {
|
func HostDetail(d HostDetailData) templ.Component {
|
||||||
@@ -64,7 +68,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 25, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 29, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -99,7 +103,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 30, Col: 47}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 34, Col: 47}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -134,7 +138,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt))
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lastSeenLabel(d.Tile.LastSeenAt))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 32, Col: 107}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 36, Col: 107}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -147,7 +151,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tileStatus(d.Tile.Latest))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 33, Col: 59}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 37, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -160,7 +164,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.MAC)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 39, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 43, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -173,7 +177,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s:%d", d.Tile.Host.WoLBroadcastIP, d.Tile.Host.WoLPort))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 43, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 47, Col: 81}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -191,7 +195,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Latest.FailedStage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 48, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 52, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -210,7 +214,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical))
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d critical", d.Tile.SpecDiffCritical))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 54, Col: 76}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 58, Col: 76}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -233,7 +237,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 62, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 66, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -246,7 +250,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 64, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 68, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -286,7 +290,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP))
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 80, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 84, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -309,7 +313,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var18 templ.SafeURL
|
var templ_7745c5c3_Var18 templ.SafeURL
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)))
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/start", d.Tile.Host.ID)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 88, Col: 96}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 92, Col: 96}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -333,7 +337,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 templ.SafeURL
|
var templ_7745c5c3_Var19 templ.SafeURL
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)))
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/override-wipe", d.Tile.Host.ID)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 95, Col: 104}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 99, Col: 104}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -352,7 +356,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var20 templ.SafeURL
|
var templ_7745c5c3_Var20 templ.SafeURL
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)))
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/reports/%d", d.Tile.Latest.ID)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 100, Col: 95}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 104, Col: 95}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -370,7 +374,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 templ.SafeURL
|
var templ_7745c5c3_Var21 templ.SafeURL
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)))
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/hosts/%d/delete", d.Tile.Host.ID)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 102, Col: 96}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 106, Col: 96}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -398,7 +402,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(d.SpecDiffs)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 111, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 115, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -434,7 +438,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Field)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 115, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 119, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -447,7 +451,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var26 string
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Expected)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 116, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 120, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -460,7 +464,7 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(diff.Actual)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 117, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 121, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -477,74 +481,48 @@ func HostDetail(d HostDetailData) templ.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d.Tile.Latest != nil {
|
if d.Tile.Latest != nil {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<section class=\"detail-section\"><h2>Log</h2><div class=\"detail-log\" id=\"")
|
templ_7745c5c3_Err = LogTabs(d.Tile.Latest.ID, d.LogReplay).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<section class=\"detail-section detail-host-meta\"><details><summary><h2>Host details</h2></summary> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if d.Tile.Host.Notes != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"detail-notes\"><h3>Notes</h3><p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", d.Tile.Latest.ID))
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 130, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 139, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" sse-swap=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"detail-spec\"><h3>Expected spec</h3><pre class=\"detail-spec-yaml\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", d.Tile.Latest.ID))
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 131, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 144, Col: 66}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-swap=\"beforeend\"></div></section>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</pre></div></details></section></section>")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<section class=\"detail-section detail-host-meta\"><details><summary><h2>Host details</h2></summary> ")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if d.Tile.Host.Notes != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"detail-notes\"><h3>Notes</h3><p>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var30 string
|
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.Notes)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 143, Col: 29}
|
|
||||||
}
|
|
||||||
_, 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, 50, "</p></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"detail-spec\"><h3>Expected spec</h3><pre class=\"detail-spec-yaml\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var31 string
|
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(d.Tile.Host.ExpectedSpecYAML)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 148, Col: 66}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</pre></div></details></section></section>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -569,4 +547,245 @@ func hasCriticalDiff(diffs []model.SpecDiff) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogTabs renders an "All" tab plus one tab per stage in DefaultStageOrder.
|
||||||
|
// Switching is pure CSS: hidden radio inputs drive sibling-selector
|
||||||
|
// visibility on the panes. Each pane carries its own sse-swap target so
|
||||||
|
// live events append only to the relevant pane. The All pane is seeded
|
||||||
|
// with replay HTML so reload on an in-flight run still shows history.
|
||||||
|
func LogTabs(runID int64, replay string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var30 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var30 == nil {
|
||||||
|
templ_7745c5c3_Var30 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<section class=\"detail-section log-section\"><h2>Log</h2><div class=\"log-tabs\"><input type=\"radio\" name=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var31 string
|
||||||
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 172, Col: 62}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var32 string
|
||||||
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 172, Col: 106}
|
||||||
|
}
|
||||||
|
_, 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, 52, "\" class=\"log-tab-input log-tab-all\" checked> <label for=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var33 string
|
||||||
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-all", runID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 173, Col: 52}
|
||||||
|
}
|
||||||
|
_, 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, 53, "\" class=\"log-tab-label\">All</label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, s := range store.DefaultStageOrder {
|
||||||
|
var templ_7745c5c3_Var34 = []any{"log-tab-input", "log-tab-" + s}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<input type=\"radio\" name=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var35 string
|
||||||
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d", runID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 175, Col: 63}
|
||||||
|
}
|
||||||
|
_, 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, 55, "\" id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var36 string
|
||||||
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 175, Col: 109}
|
||||||
|
}
|
||||||
|
_, 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, 56, "\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var37 string
|
||||||
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var34).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, 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, 57, "\"> <label for=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var38 string
|
||||||
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-tab-%d-%s", runID, s))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 176, Col: 55}
|
||||||
|
}
|
||||||
|
_, 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, 58, "\" class=\"log-tab-label\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var39 string
|
||||||
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(s)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 176, Col: 83}
|
||||||
|
}
|
||||||
|
_, 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, 59, "</label>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"log-pane log-pane-all\" id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var40 string
|
||||||
|
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 180, Col: 37}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" sse-swap=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var41 string
|
||||||
|
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d", runID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 181, Col: 43}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" hx-swap=\"beforeend show:bottom\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ.Raw(replay).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, s := range store.DefaultStageOrder {
|
||||||
|
var templ_7745c5c3_Var42 = []any{"log-pane", "log-pane-" + s}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<div class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var43 string
|
||||||
|
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var42).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var44 string
|
||||||
|
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 189, Col: 44}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" sse-swap=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var45 string
|
||||||
|
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("log-%d-%s", runID, s))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/host_detail.templ`, Line: 190, Col: 50}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" hx-swap=\"beforeend show:bottom\"></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</div></section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
var _ = templruntime.GeneratedTemplate
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PipelineNode is one dot on the detail-page timeline. The template
|
// PipelineNode is one dot on the detail-page timeline. The template
|
||||||
@@ -58,12 +59,16 @@ func runStateRank(s model.RunState) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildPipeline projects (run, stages) into a linear slice of nodes
|
// BuildPipeline projects (run, stages) into a linear slice of nodes
|
||||||
// covering the whole lifecycle: pre-stage → stage rows → Completed.
|
// covering the whole lifecycle: pre-stage → all 9 stage nodes →
|
||||||
|
// Completed. Every stage in store.DefaultStageOrder always appears,
|
||||||
|
// even if its row hasn't been seeded yet — those show as "pending"
|
||||||
|
// ghosts. This way a run stuck in WaitingWoL (stages unseeded until
|
||||||
|
// /claim) still shows the full pipeline ahead of it.
|
||||||
//
|
//
|
||||||
// When run == nil we emit a ghost timeline (everything pending) so a
|
// When run == nil we emit a ghost timeline (everything pending) so a
|
||||||
// never-run host still shows what's coming.
|
// never-run host still shows what's coming.
|
||||||
func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
||||||
nodes := make([]PipelineNode, 0, len(preStageOrder)+len(stages)+1)
|
nodes := make([]PipelineNode, 0, len(preStageOrder)+len(store.DefaultStageOrder)+1)
|
||||||
|
|
||||||
// --- pre-stage nodes ---
|
// --- pre-stage nodes ---
|
||||||
for _, ps := range preStageOrder {
|
for _, ps := range preStageOrder {
|
||||||
@@ -85,14 +90,23 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
|||||||
nodes = append(nodes, n)
|
nodes = append(nodes, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- stage nodes (from stage rows) ---
|
// --- stage nodes ---
|
||||||
failedBefore := false
|
// Iterate DefaultStageOrder, not the stages slice, so the list is
|
||||||
|
// always the full 9 nodes. For each stage, prefer the persisted row
|
||||||
|
// if it exists; otherwise synthesize a ghost whose state is derived
|
||||||
|
// from run state (passed if we've advanced past this stage's
|
||||||
|
// RunState, running if we're in it, skipped if a prior stage failed,
|
||||||
|
// pending otherwise).
|
||||||
|
stageByName := make(map[string]model.Stage, len(stages))
|
||||||
for _, st := range stages {
|
for _, st := range stages {
|
||||||
n := PipelineNode{
|
stageByName[st.Name] = st
|
||||||
Name: st.Name,
|
|
||||||
StartedAt: st.StartedAt,
|
|
||||||
CompletedAt: st.CompletedAt,
|
|
||||||
}
|
}
|
||||||
|
failedBefore := false
|
||||||
|
for _, name := range store.DefaultStageOrder {
|
||||||
|
n := PipelineNode{Name: name}
|
||||||
|
if st, ok := stageByName[name]; ok {
|
||||||
|
n.StartedAt = st.StartedAt
|
||||||
|
n.CompletedAt = st.CompletedAt
|
||||||
switch {
|
switch {
|
||||||
case failedBefore:
|
case failedBefore:
|
||||||
n.State = "skipped"
|
n.State = "skipped"
|
||||||
@@ -108,6 +122,10 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
|||||||
default:
|
default:
|
||||||
n.State = "pending"
|
n.State = "pending"
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Ghost: no row seeded yet. Derive from run state.
|
||||||
|
n.State = ghostStageState(run, name, failedBefore)
|
||||||
|
}
|
||||||
nodes = append(nodes, n)
|
nodes = append(nodes, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +140,53 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ghostStageState derives a pipeline-node state for a stage with no DB
|
||||||
|
// row — either the run hasn't reached /claim yet (pre-seed) or the stage
|
||||||
|
// is simply later than the run's current state. Mirrors the seeded-row
|
||||||
|
// logic so a ghost node transitions through the same visual states as a
|
||||||
|
// real one.
|
||||||
|
func ghostStageState(run *model.Run, name string, failedBefore bool) string {
|
||||||
|
if failedBefore {
|
||||||
|
return "skipped"
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
// Failed/FailedHolding: anything past the failed stage is skipped.
|
||||||
|
if run.State == model.StateFailed || run.State == model.StateFailedHolding {
|
||||||
|
if run.FailedStage != "" {
|
||||||
|
failedRank, ok1 := stageRank(run.FailedStage)
|
||||||
|
myRank, ok2 := stageRank(name)
|
||||||
|
if ok1 && ok2 && myRank > failedRank {
|
||||||
|
return "skipped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
stageState, ok := stageStateByName(name)
|
||||||
|
if !ok {
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case run.State == stageState:
|
||||||
|
return "running"
|
||||||
|
case runStateRank(run.State) > runStateRank(stageState):
|
||||||
|
return "passed"
|
||||||
|
}
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageRank returns the ordinal of a stage within DefaultStageOrder,
|
||||||
|
// used to decide which stages are "after" a failed stage.
|
||||||
|
func stageRank(name string) (int, bool) {
|
||||||
|
for i, s := range store.DefaultStageOrder {
|
||||||
|
if s == name {
|
||||||
|
return i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, false
|
||||||
|
}
|
||||||
|
|
||||||
// firstStageState returns the stage-state the run was in when it failed,
|
// firstStageState returns the stage-state the run was in when it failed,
|
||||||
// or the current state for runs still in-flight. Used only by the
|
// or the current state for runs still in-flight. Used only by the
|
||||||
// pre-stage "past" check to decide if a Booting node should render
|
// pre-stage "past" check to decide if a Booting node should render
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
|
"vetting/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PipelineNode is one dot on the detail-page timeline. The template
|
// PipelineNode is one dot on the detail-page timeline. The template
|
||||||
@@ -66,12 +67,16 @@ func runStateRank(s model.RunState) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildPipeline projects (run, stages) into a linear slice of nodes
|
// BuildPipeline projects (run, stages) into a linear slice of nodes
|
||||||
// covering the whole lifecycle: pre-stage → stage rows → Completed.
|
// covering the whole lifecycle: pre-stage → all 9 stage nodes →
|
||||||
|
// Completed. Every stage in store.DefaultStageOrder always appears,
|
||||||
|
// even if its row hasn't been seeded yet — those show as "pending"
|
||||||
|
// ghosts. This way a run stuck in WaitingWoL (stages unseeded until
|
||||||
|
// /claim) still shows the full pipeline ahead of it.
|
||||||
//
|
//
|
||||||
// When run == nil we emit a ghost timeline (everything pending) so a
|
// When run == nil we emit a ghost timeline (everything pending) so a
|
||||||
// never-run host still shows what's coming.
|
// never-run host still shows what's coming.
|
||||||
func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
||||||
nodes := make([]PipelineNode, 0, len(preStageOrder)+len(stages)+1)
|
nodes := make([]PipelineNode, 0, len(preStageOrder)+len(store.DefaultStageOrder)+1)
|
||||||
|
|
||||||
// --- pre-stage nodes ---
|
// --- pre-stage nodes ---
|
||||||
for _, ps := range preStageOrder {
|
for _, ps := range preStageOrder {
|
||||||
@@ -93,14 +98,23 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
|||||||
nodes = append(nodes, n)
|
nodes = append(nodes, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- stage nodes (from stage rows) ---
|
// --- stage nodes ---
|
||||||
failedBefore := false
|
// Iterate DefaultStageOrder, not the stages slice, so the list is
|
||||||
|
// always the full 9 nodes. For each stage, prefer the persisted row
|
||||||
|
// if it exists; otherwise synthesize a ghost whose state is derived
|
||||||
|
// from run state (passed if we've advanced past this stage's
|
||||||
|
// RunState, running if we're in it, skipped if a prior stage failed,
|
||||||
|
// pending otherwise).
|
||||||
|
stageByName := make(map[string]model.Stage, len(stages))
|
||||||
for _, st := range stages {
|
for _, st := range stages {
|
||||||
n := PipelineNode{
|
stageByName[st.Name] = st
|
||||||
Name: st.Name,
|
|
||||||
StartedAt: st.StartedAt,
|
|
||||||
CompletedAt: st.CompletedAt,
|
|
||||||
}
|
}
|
||||||
|
failedBefore := false
|
||||||
|
for _, name := range store.DefaultStageOrder {
|
||||||
|
n := PipelineNode{Name: name}
|
||||||
|
if st, ok := stageByName[name]; ok {
|
||||||
|
n.StartedAt = st.StartedAt
|
||||||
|
n.CompletedAt = st.CompletedAt
|
||||||
switch {
|
switch {
|
||||||
case failedBefore:
|
case failedBefore:
|
||||||
n.State = "skipped"
|
n.State = "skipped"
|
||||||
@@ -116,6 +130,10 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
|||||||
default:
|
default:
|
||||||
n.State = "pending"
|
n.State = "pending"
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Ghost: no row seeded yet. Derive from run state.
|
||||||
|
n.State = ghostStageState(run, name, failedBefore)
|
||||||
|
}
|
||||||
nodes = append(nodes, n)
|
nodes = append(nodes, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +148,53 @@ func BuildPipeline(run *model.Run, stages []model.Stage) []PipelineNode {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ghostStageState derives a pipeline-node state for a stage with no DB
|
||||||
|
// row — either the run hasn't reached /claim yet (pre-seed) or the stage
|
||||||
|
// is simply later than the run's current state. Mirrors the seeded-row
|
||||||
|
// logic so a ghost node transitions through the same visual states as a
|
||||||
|
// real one.
|
||||||
|
func ghostStageState(run *model.Run, name string, failedBefore bool) string {
|
||||||
|
if failedBefore {
|
||||||
|
return "skipped"
|
||||||
|
}
|
||||||
|
if run == nil {
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
// Failed/FailedHolding: anything past the failed stage is skipped.
|
||||||
|
if run.State == model.StateFailed || run.State == model.StateFailedHolding {
|
||||||
|
if run.FailedStage != "" {
|
||||||
|
failedRank, ok1 := stageRank(run.FailedStage)
|
||||||
|
myRank, ok2 := stageRank(name)
|
||||||
|
if ok1 && ok2 && myRank > failedRank {
|
||||||
|
return "skipped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
stageState, ok := stageStateByName(name)
|
||||||
|
if !ok {
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case run.State == stageState:
|
||||||
|
return "running"
|
||||||
|
case runStateRank(run.State) > runStateRank(stageState):
|
||||||
|
return "passed"
|
||||||
|
}
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
// stageRank returns the ordinal of a stage within DefaultStageOrder,
|
||||||
|
// used to decide which stages are "after" a failed stage.
|
||||||
|
func stageRank(name string) (int, bool) {
|
||||||
|
for i, s := range store.DefaultStageOrder {
|
||||||
|
if s == name {
|
||||||
|
return i, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, false
|
||||||
|
}
|
||||||
|
|
||||||
// firstStageState returns the stage-state the run was in when it failed,
|
// firstStageState returns the stage-state the run was in when it failed,
|
||||||
// or the current state for runs still in-flight. Used only by the
|
// or the current state for runs still in-flight. Used only by the
|
||||||
// pre-stage "past" check to decide if a Booting node should render
|
// pre-stage "past" check to decide if a Booting node should render
|
||||||
@@ -309,7 +374,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 210, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 275, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -322,7 +387,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 211, Col: 36}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 276, Col: 36}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -335,7 +400,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 212, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 277, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ func seedStages() []model.Stage {
|
|||||||
|
|
||||||
func TestBuildPipeline_NoRun(t *testing.T) {
|
func TestBuildPipeline_NoRun(t *testing.T) {
|
||||||
nodes := BuildPipeline(nil, nil)
|
nodes := BuildPipeline(nil, nil)
|
||||||
if len(nodes) != len(preStageOrder)+1 {
|
// Ghost pipeline: 3 pre-stages + 9 stage ghosts + 1 terminal = 13
|
||||||
// No stage rows = just pre-stages + Completed.
|
// nodes, all pending.
|
||||||
t.Fatalf("len = %d, want %d", len(nodes), len(preStageOrder)+1)
|
if len(nodes) != 13 {
|
||||||
|
t.Fatalf("len = %d, want 13", len(nodes))
|
||||||
}
|
}
|
||||||
for i, n := range nodes {
|
for i, n := range nodes {
|
||||||
if n.State != "pending" {
|
if n.State != "pending" {
|
||||||
@@ -47,6 +48,57 @@ func TestBuildPipeline_NoRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBuildPipeline_GhostStagesBeforeClaim models the real WaitingWoL
|
||||||
|
// case: the run exists but agent hasn't called /claim yet, so there are
|
||||||
|
// no stage rows. Pipeline must still render all 9 stage nodes as ghosts
|
||||||
|
// so the operator sees the full timeline ahead of them.
|
||||||
|
func TestBuildPipeline_GhostStagesBeforeClaim(t *testing.T) {
|
||||||
|
run := &model.Run{State: model.StateWaitingWoL}
|
||||||
|
nodes := BuildPipeline(run, nil)
|
||||||
|
if len(nodes) != 13 {
|
||||||
|
t.Fatalf("len = %d, want 13", len(nodes))
|
||||||
|
}
|
||||||
|
if nodes[idxQueued].State != "passed" {
|
||||||
|
t.Errorf("Queued = %q, want passed", nodes[idxQueued].State)
|
||||||
|
}
|
||||||
|
if nodes[idxWaitingWoL].State != "running" {
|
||||||
|
t.Errorf("WaitingWoL = %q, want running", nodes[idxWaitingWoL].State)
|
||||||
|
}
|
||||||
|
// All 9 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nodes[idxCompleted].State != "pending" {
|
||||||
|
t.Errorf("Completed = %q, want pending", nodes[idxCompleted].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildPipeline_GhostStagesDuringStage models the in-flight case
|
||||||
|
// with only some stage rows seeded: later stages must still appear as
|
||||||
|
// 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.
|
||||||
|
stages := []model.Stage{
|
||||||
|
{Name: "Inventory", Ordinal: 0, State: model.StagePassed},
|
||||||
|
{Name: "SpecValidate", Ordinal: 1, State: model.StagePassed},
|
||||||
|
}
|
||||||
|
nodes := BuildPipeline(run, stages)
|
||||||
|
if len(nodes) != 13 {
|
||||||
|
t.Fatalf("len = %d, want 13", 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} {
|
||||||
|
if nodes[i].State != "pending" {
|
||||||
|
t.Errorf("%s (ghost) = %q, want pending", nodes[i].Name, nodes[i].State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildPipeline_Running(t *testing.T) {
|
func TestBuildPipeline_Running(t *testing.T) {
|
||||||
run := &model.Run{State: model.StateSMART}
|
run := &model.Run{State: model.StateSMART}
|
||||||
stages := seedStages()
|
stages := seedStages()
|
||||||
|
|||||||
Reference in New Issue
Block a user