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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;i<n;i++)segLens.push(cs[i+1]-cs[i]);
|
||||
const weights=segLens.slice();
|
||||
if(CI_DUR>0&&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;i<n;i++){
|
||||
if(target<=acc+weights[i]||i===n-1){
|
||||
const local=weights[i]>0?(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<spineFrac){
|
||||
dist=weightedSpineDist(p/spineFrac); onLoop=false;
|
||||
}else{
|
||||
dist=spineLen+loopLen*((p-spineFrac)/(1-spineFrac)); onLoop=true;
|
||||
}
|
||||
let pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
|
||||
pulse.style.left=pt.x+'px';pulse.style.top=pt.y+'px';
|
||||
pulse.style.background=onLoop?'var(--violet)':'var(--amber)';
|
||||
pulse.style.boxShadow=onLoop?'0 0 15px 4px rgba(155,140,255,.75)':'0 0 15px 4px rgba(245,185,66,.75)';
|
||||
@@ -370,6 +403,8 @@ async function init(){
|
||||
if(typeof data.ns==='string') NS=data.ns;
|
||||
if(Array.isArray(data.stages)) STAGES=data.stages;
|
||||
if(Array.isArray(data.timeline)) TIMELINE=data.timeline;
|
||||
if(typeof data.ci_duration_s==='number') CI_DUR=data.ci_duration_s;
|
||||
if(typeof data.cd_duration_s==='number') CD_DUR=data.cd_duration_s;
|
||||
if(data.version) document.getElementById('ver').textContent=data.version;
|
||||
}catch(e){
|
||||
console.error('atlas: failed to load /api/atlas.json —',e);
|
||||
|
||||
Reference in New Issue
Block a user