ui: keep detail-page SSE swaps live after the first outerHTML replace
Pipeline fragment payload was a bare <div class=pipeline>, but the sse-swap=pipeline-N wrapper lived only in the page shell. The first outerHTML swap destroyed the wrapper, so every subsequent pipeline event had nothing to target — forcing a manual refresh. RenderPipelineString now emits the full <section id=pipeline-N sse-swap=... hx-swap=outerHTML> wrapper, used from both the shell and the orchestrator publish path. Also drop the red-bar styling from the empty DetailHold placeholder: the wrapper's detail-hold class was painting an unconditional red band between Pipeline and Actions whenever no hold was active.
This commit is contained in:
@@ -34,15 +34,7 @@ templ HostDetail(d HostDetailData) {
|
|||||||
@DetailSummary(d)
|
@DetailSummary(d)
|
||||||
|
|
||||||
if d.Tile.Latest != nil {
|
if d.Tile.Latest != nil {
|
||||||
<section
|
@PipelineSection(d.Tile.Latest, BuildPipeline(d.Tile.Latest, d.Stages))
|
||||||
id={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
|
||||||
class="detail-section"
|
|
||||||
sse-swap={ fmt.Sprintf("pipeline-%d", d.Tile.Latest.ID) }
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
<h2>Pipeline</h2>
|
|
||||||
@Pipeline(BuildPipeline(d.Tile.Latest, d.Stages))
|
|
||||||
</section>
|
|
||||||
} else {
|
} else {
|
||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<h2>Pipeline</h2>
|
<h2>Pipeline</h2>
|
||||||
@@ -206,17 +198,24 @@ templ DetailSpecDiffs(d HostDetailData) {
|
|||||||
// target. Keyed on run ID for the same reason as DetailSpecDiffs.
|
// target. Keyed on run ID for the same reason as DetailSpecDiffs.
|
||||||
templ DetailHold(d HostDetailData) {
|
templ DetailHold(d HostDetailData) {
|
||||||
if d.Tile.Latest != nil {
|
if d.Tile.Latest != nil {
|
||||||
|
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
||||||
<section
|
<section
|
||||||
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||||
class="detail-section detail-hold"
|
class="detail-section detail-hold"
|
||||||
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
>
|
>
|
||||||
if d.Tile.Latest.State == model.StateFailedHolding && d.Tile.Latest.HoldIP != "" {
|
|
||||||
<h2>Host is holding — SSH available</h2>
|
<h2>Host is holding — SSH available</h2>
|
||||||
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
<code class="hold-ssh">{ sshInvocation(d.Tile.HoldKeyPath, d.Tile.Latest.HoldIP) }</code>
|
||||||
}
|
|
||||||
</section>
|
</section>
|
||||||
|
} else {
|
||||||
|
<section
|
||||||
|
id={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||||
|
class="detail-hold-placeholder"
|
||||||
|
sse-swap={ fmt.Sprintf("detail-hold-%d", d.Tile.Latest.ID) }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
></section>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -280,11 +280,35 @@ templ Pipeline(nodes []PipelineNode) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PipelineSection wraps Pipeline in the same <section id=pipeline-N
|
||||||
|
// sse-swap=pipeline-N hx-swap=outerHTML> the runner targets. Used both
|
||||||
|
// from the initial detail-page shell and from RenderPipelineString so
|
||||||
|
// the wrapper is present on the wire and after every SSE swap — without
|
||||||
|
// this, the first outerHTML swap would replace the section with a bare
|
||||||
|
// <div class=pipeline>, wiping out the sse-swap attribute and freezing
|
||||||
|
// the pipeline until full page reload.
|
||||||
|
templ PipelineSection(run *model.Run, nodes []PipelineNode) {
|
||||||
|
<section
|
||||||
|
id={ fmt.Sprintf("pipeline-%d", run.ID) }
|
||||||
|
class="detail-section"
|
||||||
|
sse-swap={ fmt.Sprintf("pipeline-%d", run.ID) }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
@Pipeline(nodes)
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
// RenderPipelineString is the one-shot renderer the orchestrator
|
// RenderPipelineString is the one-shot renderer the orchestrator
|
||||||
// registers at startup so it can publish pipeline fragments over SSE
|
// registers at startup so it can publish pipeline fragments over SSE
|
||||||
// without pulling in the template package directly.
|
// without pulling in the template package directly. Returns the full
|
||||||
|
// PipelineSection wrapper so repeat outerHTML swaps preserve the
|
||||||
|
// sse-swap target.
|
||||||
func RenderPipelineString(run *model.Run, stages []model.Stage) string {
|
func RenderPipelineString(run *model.Run, stages []model.Stage) string {
|
||||||
|
if run == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
_ = Pipeline(BuildPipeline(run, stages)).Render(context.Background(), &buf)
|
_ = PipelineSection(run, BuildPipeline(run, stages)).Render(context.Background(), &buf)
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -419,12 +419,87 @@ func Pipeline(nodes []PipelineNode) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PipelineSection wraps Pipeline in the same <section id=pipeline-N
|
||||||
|
// sse-swap=pipeline-N hx-swap=outerHTML> the runner targets. Used both
|
||||||
|
// from the initial detail-page shell and from RenderPipelineString so
|
||||||
|
// the wrapper is present on the wire and after every SSE swap — without
|
||||||
|
// this, the first outerHTML swap would replace the section with a bare
|
||||||
|
// <div class=pipeline>, wiping out the sse-swap attribute and freezing
|
||||||
|
// the pipeline until full page reload.
|
||||||
|
func PipelineSection(run *model.Run, nodes []PipelineNode) 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_Var11 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var11 == nil {
|
||||||
|
templ_7745c5c3_Var11 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<section id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 292, Col: 41}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"detail-section\" sse-swap=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("pipeline-%d", run.ID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/pipeline.templ`, Line: 294, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" hx-swap=\"outerHTML\"><h2>Pipeline</h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Pipeline(nodes).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// RenderPipelineString is the one-shot renderer the orchestrator
|
// RenderPipelineString is the one-shot renderer the orchestrator
|
||||||
// registers at startup so it can publish pipeline fragments over SSE
|
// registers at startup so it can publish pipeline fragments over SSE
|
||||||
// without pulling in the template package directly.
|
// without pulling in the template package directly. Returns the full
|
||||||
|
// PipelineSection wrapper so repeat outerHTML swaps preserve the
|
||||||
|
// sse-swap target.
|
||||||
func RenderPipelineString(run *model.Run, stages []model.Stage) string {
|
func RenderPipelineString(run *model.Run, stages []model.Stage) string {
|
||||||
|
if run == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
_ = Pipeline(BuildPipeline(run, stages)).Render(context.Background(), &buf)
|
_ = PipelineSection(run, BuildPipeline(run, stages)).Render(context.Background(), &buf)
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"vetting/internal/model"
|
"vetting/internal/model"
|
||||||
@@ -200,3 +201,24 @@ func TestBuildPipeline_PreStageRunning_WaitingReboot(t *testing.T) {
|
|||||||
t.Errorf("Booting = %q, want pending", nodes[idxBooting].State)
|
t.Errorf("Booting = %q, want pending", nodes[idxBooting].State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRenderPipelineString_IncludesSection asserts the orchestrator-
|
||||||
|
// published fragment carries the <section id=pipeline-N sse-swap=...
|
||||||
|
// hx-swap=outerHTML> wrapper. Without this, the first outerHTML swap
|
||||||
|
// would replace the section with a bare <div class=pipeline>, wiping
|
||||||
|
// out the sse-swap attribute and freezing every subsequent pipeline
|
||||||
|
// event until page reload.
|
||||||
|
func TestRenderPipelineString_IncludesSection(t *testing.T) {
|
||||||
|
run := &model.Run{ID: 42, State: model.StateSMART}
|
||||||
|
html := RenderPipelineString(run, seedStages())
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="pipeline-42"`,
|
||||||
|
`sse-swap="pipeline-42"`,
|
||||||
|
`hx-swap="outerHTML"`,
|
||||||
|
`<h2>Pipeline</h2>`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(html, want) {
|
||||||
|
t.Errorf("RenderPipelineString missing %q in:\n%s", want, html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user