generated from mathias/template-go-web
feat(atlas): weight replay pacing on CI/CD stages by real job durations (#5)
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:
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user