fix(ui): fit pipeline timeline without horizontal scroll
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:
@@ -44,7 +44,7 @@ a:hover { text-decoration: underline; }
|
||||
.topbar .heartbeat { color: var(--text-dim); font-family: var(--mono); font-size: 12px; }
|
||||
.topbar .logout-form { margin: 0; }
|
||||
|
||||
main { max-width: 1280px; margin: 0 auto; padding: 24px; }
|
||||
main { max-width: 1440px; margin: 0 auto; padding: 24px; }
|
||||
|
||||
button, .button, .button-secondary {
|
||||
appearance: none;
|
||||
@@ -492,16 +492,16 @@ body.bare main { max-width: none; }
|
||||
}
|
||||
|
||||
/* ===== 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. */
|
||||
15 nodes (3 pre-stage + 11 stage + Completed). flex:1 on every node
|
||||
so they share the full width evenly. Multi-word labels
|
||||
(stageDisplayName in pipeline.templ) wrap on two lines so nodes pack
|
||||
tight without horizontal scroll. */
|
||||
.pipeline {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 28px 12px 16px;
|
||||
padding: 28px 8px 16px;
|
||||
background: #0b0d12;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
@@ -512,8 +512,8 @@ body.bare main { max-width: none; }
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1 1 0;
|
||||
min-width: 72px;
|
||||
padding: 0 4px;
|
||||
min-width: 56px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.stage-dot {
|
||||
width: 28px;
|
||||
@@ -543,6 +543,7 @@ body.bare main { max-width: none; }
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.stage-node-passed .stage-name { color: var(--text); }
|
||||
.stage-node-running .stage-name { color: var(--accent); font-weight: 600; }
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user