feat(atlas): weight replay pacing on CI/CD stages by real job durations (#5)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped

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.
This commit is contained in:
2026-07-20 23:22:53 +02:00
parent b2761a4747
commit 6967d12d1d
5 changed files with 134 additions and 11 deletions
+7 -5
View File
@@ -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
+37 -2
View File
@@ -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":
+50
View File
@@ -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
}