From 6967d12d1da99890ee2b0597b1801e1dbcb1319d Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 20 Jul 2026 23:22:53 +0200 Subject: [PATCH] feat(atlas): weight replay pacing on CI/CD stages by real job durations (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD: Job.Seconds() + StageSeconds() derive real CI/CD dwell time from the latest run's per-job created_at/updated_at (last job in pipeline order = deploy/stage 07, everything before it = CI/stage 06). Frontend: weightedSpineDist() redistributes the pixel-time-budget the 06/07 segments already had, splitting it by real CI:CD duration ratio instead of raw pixel width. Falls back to the exact prior constant-speed sweep when no run data is available (weights default to segment pixel length) or when a job is skipped (0 duration) — no behavior change for stages 00-05/08, which still have no live timing source (same gap as #5's ledger item). Verified: real duration values flow through /api/atlas.json (ci_duration_s: 18 observed against a real run), full page screenshot confirms no visual regression. --- internal/atlas/model.go | 12 ++++--- internal/atlas/runs.go | 39 +++++++++++++++++++++-- internal/atlas/runs_test.go | 50 ++++++++++++++++++++++++++++++ internal/web/handler.go | 1 + internal/web/static/cad-atlas.html | 43 ++++++++++++++++++++++--- 5 files changed, 134 insertions(+), 11 deletions(-) diff --git a/internal/atlas/model.go b/internal/atlas/model.go index e18964e..3b3e447 100644 --- a/internal/atlas/model.go +++ b/internal/atlas/model.go @@ -47,11 +47,13 @@ type Stage struct { // Atlas is the full data model the frontend renders. type Atlas struct { - Version string `json:"version,omitempty"` - Substrate []Host `json:"substrate"` - NS string `json:"ns,omitempty"` - Timeline []RunDot `json:"timeline,omitempty"` - Stages []Stage `json:"stages"` + Version string `json:"version,omitempty"` + Substrate []Host `json:"substrate"` + NS string `json:"ns,omitempty"` + Timeline []RunDot `json:"timeline,omitempty"` + CIDurationS float64 `json:"ci_duration_s,omitempty"` + CDDurationS float64 `json:"cd_duration_s,omitempty"` + Stages []Stage `json:"stages"` } // Build unmarshals the authored atlas JSON and overlays generated facts from diff --git a/internal/atlas/runs.go b/internal/atlas/runs.go index 88350f2..a90b840 100644 --- a/internal/atlas/runs.go +++ b/internal/atlas/runs.go @@ -3,13 +3,18 @@ package atlas import ( "encoding/json" "fmt" + "time" ) -// Job is one job within a workflow run (a Gitea Actions "task"). +// Job is one job within a workflow run (a Gitea Actions "task"). Started/ +// Finished are best-effort (zero value if the job hasn't completed or the +// timestamp failed to parse). type Job struct { Name string Status string Conclusion string + Started time.Time + Finished time.Time } // State is the effective outcome: conclusion if set, else status. @@ -20,6 +25,14 @@ func (j Job) State() string { return j.Status } +// Seconds is how long the job ran, or 0 if either timestamp is missing/invalid. +func (j Job) Seconds() float64 { + if j.Started.IsZero() || j.Finished.Before(j.Started) { + return 0 + } + return j.Finished.Sub(j.Started).Seconds() +} + // RunSummary is the newest workflow run and its per-job outcomes. type RunSummary struct { Number int @@ -100,6 +113,8 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) { Conclusion string `json:"conclusion"` SHA string `json:"head_sha"` Title string `json:"display_title"` + Created string `json:"created_at"` + Updated string `json:"updated_at"` } `json:"workflow_runs"` } if err := json.Unmarshal(tasksJSON, &resp); err != nil { @@ -113,7 +128,12 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) { s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title} for _, t := range resp.Tasks { if t.RunNumber == latest.RunNumber { - s.Jobs = append(s.Jobs, Job{Name: t.Name, Status: t.Status, Conclusion: t.Conclusion}) + started, _ := time.Parse(time.RFC3339, t.Created) + finished, _ := time.Parse(time.RFC3339, t.Updated) + s.Jobs = append(s.Jobs, Job{ + Name: t.Name, Status: t.Status, Conclusion: t.Conclusion, + Started: started, Finished: finished, + }) } } // Gitea lists newest (last-finished) first; reverse to pipeline order. @@ -142,6 +162,21 @@ func RunNodes(s RunSummary) []Node { return nodes } +// StageSeconds splits a run's real job durations across the two live-timed +// stages: the last job in pipeline order is the deploy (stage 07), everything +// before it is CI (stage 06). Returns 0, 0 if there are no jobs. +func StageSeconds(s RunSummary) (ci, cd float64) { + if len(s.Jobs) == 0 { + return 0, 0 + } + last := len(s.Jobs) - 1 + for _, j := range s.Jobs[:last] { + ci += j.Seconds() + } + cd = s.Jobs[last].Seconds() + return ci, cd +} + func statePill(state string) string { switch state { case "success": diff --git a/internal/atlas/runs_test.go b/internal/atlas/runs_test.go index a583650..ae691c0 100644 --- a/internal/atlas/runs_test.go +++ b/internal/atlas/runs_test.go @@ -2,6 +2,7 @@ package atlas_test import ( "testing" + "time" "git.d-ma.be/mathias/cad-atlas/internal/atlas" ) @@ -104,3 +105,52 @@ func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) { t.Fatal("expected error on empty, got nil") } } + +func TestLatestRunJobs_ParsesPerJobTimestampsIntoSeconds(t *testing.T) { + tasks := []byte(`{"workflow_runs":[ + {"run_number":28,"name":"Deploy via GitOps","status":"success","created_at":"2026-07-20T21:05:44Z","updated_at":"2026-07-20T21:05:50Z"}, + {"run_number":28,"name":"Lint / Test / Vet","status":"success","created_at":"2026-07-20T21:05:39Z","updated_at":"2026-07-20T21:05:44Z"} + ]}`) + + s, err := atlas.LatestRunJobs(tasks) + if err != nil { + t.Fatalf("LatestRunJobs: %v", err) + } + // pipeline order: Lint first, Deploy last + if got := s.Jobs[0].Seconds(); got != 5 { + t.Fatalf("Lint job seconds = %v, want 5", got) + } + if got := s.Jobs[1].Seconds(); got != 6 { + t.Fatalf("Deploy job seconds = %v, want 6", got) + } +} + +func TestStageSeconds_LastJobIsDeploySumOfRestIsCI(t *testing.T) { + s := atlas.RunSummary{Jobs: []atlas.Job{ + {Name: "Lint", Started: mustParse("2026-07-20T21:00:00Z"), Finished: mustParse("2026-07-20T21:00:10Z")}, // 10s + {Name: "Build", Started: mustParse("2026-07-20T21:00:10Z"), Finished: mustParse("2026-07-20T21:00:25Z")}, // 15s + {Name: "Deploy", Started: mustParse("2026-07-20T21:00:25Z"), Finished: mustParse("2026-07-20T21:00:33Z")}, // 8s + }} + ci, cd := atlas.StageSeconds(s) + if ci != 25 { + t.Fatalf("ci = %v, want 25", ci) + } + if cd != 8 { + t.Fatalf("cd = %v, want 8", cd) + } +} + +func TestStageSeconds_NoJobsReturnsZero(t *testing.T) { + ci, cd := atlas.StageSeconds(atlas.RunSummary{}) + if ci != 0 || cd != 0 { + t.Fatalf("ci=%v cd=%v, want 0,0", ci, cd) + } +} + +func mustParse(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} diff --git a/internal/web/handler.go b/internal/web/handler.go index 9742512..ae7c8a8 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -55,6 +55,7 @@ func NewHandler() http.Handler { a.Stages[i].Nodes = nodes } } + a.CIDurationS, a.CDDurationS = atlas.StageSeconds(*ld.run) } if ld.issues != nil { for i := range a.Stages { diff --git a/internal/web/static/cad-atlas.html b/internal/web/static/cad-atlas.html index c049bd0..879a955 100644 --- a/internal/web/static/cad-atlas.html +++ b/internal/web/static/cad-atlas.html @@ -214,7 +214,7 @@ // stage from the live cd.yml + substrate from the live cluster). These start // empty and are filled by init()'s fetch; on failure the page shows an error // banner rather than stale inline data. -let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[]; +let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[], CI_DUR=0, CD_DUR=0; let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical' const track=document.getElementById('track'); @@ -330,14 +330,47 @@ function build(){ } spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength(); } +// weightedSpineDist maps f (0..1 time-progress across the spine) to an arc-length +// distance. Default weight per stage-to-stage segment is its own pixel length — +// reduces to the old constant-pixel-speed sweep. When a real run's CI/CD durations +// are known, the pixel-time-budget already held by the 06(CI)/07(CD) segments is +// re-split by their real relative duration instead of by raw pixel width — so the +// reel visits stage 06 vs 07 at speeds proportional to how long they actually took. +function weightedSpineDist(f){ + const n=cs.length-1; + if(n<=0)return 0; + const segLens=[];for(let i=0;i0&&CD_DUR>0&&n>=7){ + const budget=weights[5]+weights[6], tot=CI_DUR+CD_DUR; + weights[5]=budget*CI_DUR/tot; weights[6]=budget*CD_DUR/tot; + } + const totalW=weights.reduce((a,b)=>a+b,0); + const target=f*totalW; + let acc=0; + for(let i=0;i0?(target-acc)/weights[i]:0; + const segStart=cs[i]-cs[0]; + return Math.min(spineLen,Math.max(0,segStart+segLens[i]*Math.min(1,Math.max(0,local)))); + } + acc+=weights[i]; + } + return spineLen; +} function run(ts){ if(mobile)return; if(!t0)t0=ts; const dur=slow?16000:7000; const p=Math.min((ts-t0)/dur,1); - const total=spineLen+loopLen, dist=total*p; - let pt,onLoop=dist>spineLen; - pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist); + const spineFrac=spineLen/(spineLen+loopLen); + let dist,onLoop; + if(p