fix(ui): fit pipeline timeline without horizontal scroll
CI / Lint + build + test (push) Successful in 1m39s
Release / release (push) Successful in 7m30s

15 nodes (3 pre-stage + 11 stage + Completed) exceeded the 1280px main
container's usable width, producing a horizontal scrollbar under the
pipeline on the run page. Widen main to 1440px, tighten per-node min
widths, drop the scrollbar, and split camelCase labels so multi-word
stages ("WaitingReboot", "SpecValidate", "CPUStress") wrap onto two
lines instead of forcing node width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 22:51:10 -04:00
parent 3656af9823
commit e73b221a8c
3 changed files with 72 additions and 15 deletions
+29 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"strings"
"time"
"vetting/internal/model"
@@ -250,6 +251,33 @@ func stageDuration(n PipelineNode) string {
}
}
// stageDisplayName turns the internal single-word state/stage identifier
// into a human-readable label by inserting spaces before interior capital
// letters. "WaitingReboot" → "Waiting Reboot", "SpecValidate" →
// "Spec Validate", "CPUStress" → "CPU Stress". The space lets the
// pipeline node wrap the label onto two lines on narrow layouts instead
// of forcing horizontal scroll — single-word names ("Inventory",
// "Completed") pass through unchanged.
func stageDisplayName(name string) string {
var b strings.Builder
b.Grow(len(name) + 2)
for i, r := range name {
if i > 0 && r >= 'A' && r <= 'Z' {
prev := rune(name[i-1])
// Insert a space at every lower→upper boundary ("Spec|Validate")
// and at upper→upper when the next char is lower ("CPU|Stress",
// where we split before the S that starts a new word). This
// keeps acronyms like "GPU"/"PSU" intact.
if (prev >= 'a' && prev <= 'z') ||
(prev >= 'A' && prev <= 'Z' && i+1 < len(name) && name[i+1] >= 'a' && name[i+1] <= 'z') {
b.WriteByte(' ')
}
}
b.WriteRune(r)
}
return b.String()
}
// stageMarker returns the single-char glyph shown in the node's dot.
// Dots stay colored-via-class; the glyph is redundant-but-helpful.
func stageMarker(state string) string {
@@ -277,7 +305,7 @@ templ Pipeline(nodes []PipelineNode) {
}
<div class={ "stage-node", "stage-node-" + n.State }>
<div class={ "stage-dot", "stage-dot-" + n.State }>{ stageMarker(n.State) }</div>
<div class="stage-name">{ n.Name }</div>
<div class="stage-name">{ stageDisplayName(n.Name) }</div>
<div class="stage-duration">{ stageDuration(n) }</div>
</div>
}
+34 -6
View File
@@ -12,6 +12,7 @@ import (
"bytes"
"context"
"fmt"
"strings"
"time"
"vetting/internal/model"
@@ -258,6 +259,33 @@ func stageDuration(n PipelineNode) string {
}
}
// stageDisplayName turns the internal single-word state/stage identifier
// into a human-readable label by inserting spaces before interior capital
// letters. "WaitingReboot" → "Waiting Reboot", "SpecValidate" →
// "Spec Validate", "CPUStress" → "CPU Stress". The space lets the
// pipeline node wrap the label onto two lines on narrow layouts instead
// of forcing horizontal scroll — single-word names ("Inventory",
// "Completed") pass through unchanged.
func stageDisplayName(name string) string {
var b strings.Builder
b.Grow(len(name) + 2)
for i, r := range name {
if i > 0 && r >= 'A' && r <= 'Z' {
prev := rune(name[i-1])
// Insert a space at every lower→upper boundary ("Spec|Validate")
// and at upper→upper when the next char is lower ("CPU|Stress",
// where we split before the S that starts a new word). This
// keeps acronyms like "GPU"/"PSU" intact.
if (prev >= 'a' && prev <= 'z') ||
(prev >= 'A' && prev <= 'Z' && i+1 < len(name) && name[i+1] >= 'a' && name[i+1] <= 'z') {
b.WriteByte(' ')
}
}
b.WriteRune(r)
}
return b.String()
}
// stageMarker returns the single-char glyph shown in the node's dot.
// Dots stay colored-via-class; the glyph is redundant-but-helpful.
func stageMarker(state string) string {
@@ -378,7 +406,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(stageMarker(n.State))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 279, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 307, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -389,9 +417,9 @@ func Pipeline(nodes []PipelineNode) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(n.Name)
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(stageDisplayName(n.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 280, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 308, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -404,7 +432,7 @@ func Pipeline(nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(stageDuration(n))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 281, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 309, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -458,7 +486,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 296, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 324, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -471,7 +499,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 298, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 326, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {