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 .heartbeat { color: var(--text-dim); font-family: var(--mono); font-size: 12px; }
|
||||||
.topbar .logout-form { margin: 0; }
|
.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 {
|
button, .button, .button-secondary {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -492,16 +492,16 @@ body.bare main { max-width: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Pipeline timeline =====
|
/* ===== Pipeline timeline =====
|
||||||
13 nodes (3 pre-stage + 9 stage + Completed). flex:1 on every node
|
15 nodes (3 pre-stage + 11 stage + Completed). flex:1 on every node
|
||||||
so they share the full width evenly; overflow-x:auto only kicks in
|
so they share the full width evenly. Multi-word labels
|
||||||
on very narrow viewports. */
|
(stageDisplayName in pipeline.templ) wrap on two lines so nodes pack
|
||||||
|
tight without horizontal scroll. */
|
||||||
.pipeline {
|
.pipeline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-x: auto;
|
padding: 28px 8px 16px;
|
||||||
padding: 28px 12px 16px;
|
|
||||||
background: #0b0d12;
|
background: #0b0d12;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -512,8 +512,8 @@ body.bare main { max-width: none; }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 72px;
|
min-width: 56px;
|
||||||
padding: 0 4px;
|
padding: 0 2px;
|
||||||
}
|
}
|
||||||
.stage-dot {
|
.stage-dot {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -543,6 +543,7 @@ body.bare main { max-width: none; }
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.stage-node-passed .stage-name { color: var(--text); }
|
.stage-node-passed .stage-name { color: var(--text); }
|
||||||
.stage-node-running .stage-name { color: var(--accent); font-weight: 600; }
|
.stage-node-running .stage-name { color: var(--accent); font-weight: 600; }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"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.
|
// stageMarker returns the single-char glyph shown in the node's dot.
|
||||||
// Dots stay colored-via-class; the glyph is redundant-but-helpful.
|
// Dots stay colored-via-class; the glyph is redundant-but-helpful.
|
||||||
func stageMarker(state string) string {
|
func stageMarker(state string) string {
|
||||||
@@ -277,7 +305,7 @@ templ Pipeline(nodes []PipelineNode) {
|
|||||||
}
|
}
|
||||||
<div class={ "stage-node", "stage-node-" + n.State }>
|
<div class={ "stage-node", "stage-node-" + n.State }>
|
||||||
<div class={ "stage-dot", "stage-dot-" + n.State }>{ stageMarker(n.State) }</div>
|
<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 class="stage-duration">{ stageDuration(n) }</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"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.
|
// stageMarker returns the single-char glyph shown in the node's dot.
|
||||||
// Dots stay colored-via-class; the glyph is redundant-but-helpful.
|
// Dots stay colored-via-class; the glyph is redundant-but-helpful.
|
||||||
func stageMarker(state string) string {
|
func stageMarker(state string) string {
|
||||||
@@ -378,7 +406,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: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -389,9 +417,9 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
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(stageDisplayName(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: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -404,7 +432,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: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -458,7 +486,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -471,7 +499,7 @@ func PipelineSection(run *model.Run, nodes []PipelineNode) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user