diff --git a/internal/atlas/model.go b/internal/atlas/model.go index 74aa481..aae8790 100644 --- a/internal/atlas/model.go +++ b/internal/atlas/model.go @@ -35,10 +35,11 @@ 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"` - Stages []Stage `json:"stages"` + Version string `json:"version,omitempty"` + Substrate []Host `json:"substrate"` + NS string `json:"ns,omitempty"` + Timeline []RunDot `json:"timeline,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 6d46d1d..a6bd134 100644 --- a/internal/atlas/runs.go +++ b/internal/atlas/runs.go @@ -31,8 +31,50 @@ type RunSummary struct { // State aggregates the jobs: failure if any failed, running if any not yet // succeeded, else success. func (s RunSummary) State() string { + return aggregateState(s.Jobs) +} + +// RunDot is one run's aggregate outcome for the recent-runs timeline. +type RunDot struct { + Number int `json:"number"` + State string `json:"state"` +} + +// RecentRuns parses a Gitea `/actions/tasks` response (per-job, newest first) +// into up to n most-recent runs with their aggregate outcome, newest first. +func RecentRuns(tasksJSON []byte, n int) ([]RunDot, error) { + var resp struct { + Tasks []struct { + RunNumber int `json:"run_number"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + } `json:"workflow_runs"` + } + if err := json.Unmarshal(tasksJSON, &resp); err != nil { + return nil, fmt.Errorf("parse tasks: %w", err) + } + var order []int + jobsByRun := map[int][]Job{} + for _, t := range resp.Tasks { + if _, seen := jobsByRun[t.RunNumber]; !seen { + order = append(order, t.RunNumber) + } + jobsByRun[t.RunNumber] = append(jobsByRun[t.RunNumber], Job{Status: t.Status, Conclusion: t.Conclusion}) + } + dots := make([]RunDot, 0, n) + for _, rn := range order { + if len(dots) >= n { + break + } + dots = append(dots, RunDot{Number: rn, State: aggregateState(jobsByRun[rn])}) + } + return dots, nil +} + +// aggregateState folds per-job outcomes into a run outcome. +func aggregateState(jobs []Job) string { allSucceeded := true - for _, j := range s.Jobs { + for _, j := range jobs { switch j.State() { case "failure", "cancelled", "error": return "failure" diff --git a/internal/atlas/runs_test.go b/internal/atlas/runs_test.go index e996d20..ea24913 100644 --- a/internal/atlas/runs_test.go +++ b/internal/atlas/runs_test.go @@ -65,6 +65,30 @@ func TestRunNodes_SummaryThenPerJobColoured(t *testing.T) { } } +func TestRecentRuns_GroupsRunsNewestFirstWithAggregateState(t *testing.T) { + tasks := []byte(`{"workflow_runs":[ + {"run_number":28,"name":"Deploy","status":"success"}, + {"run_number":28,"name":"Build","status":"success"}, + {"run_number":27,"name":"Deploy","status":"completed","conclusion":"failure"}, + {"run_number":26,"name":"Build","status":"running"}, + {"run_number":25,"name":"Deploy","status":"success"} + ]}`) + + dots, err := atlas.RecentRuns(tasks, 3) + if err != nil { + t.Fatalf("RecentRuns: %v", err) + } + if len(dots) != 3 { + t.Fatalf("want 3 dots (capped), got %d: %+v", len(dots), dots) + } + want := []atlas.RunDot{{28, "success"}, {27, "failure"}, {26, "running"}} + for i := range want { + if dots[i] != want[i] { + t.Fatalf("dot[%d] = %+v, want %+v", i, dots[i], want[i]) + } + } +} + func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) { if _, err := atlas.LatestRunJobs([]byte(`{"workflow_runs":[]}`)); err == nil { t.Fatal("expected error on empty, got nil") diff --git a/internal/gitea/client.go b/internal/gitea/client.go index 480ce61..f077b14 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -21,7 +21,7 @@ func base() string { // Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first). func Runs() ([]byte, error) { - url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=20" + url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50" client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client if err != nil { diff --git a/internal/web/handler.go b/internal/web/handler.go index f9f21f2..4b21775 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -47,6 +47,7 @@ func NewHandler() http.Handler { if ld.ns != "" { a.NS = "Tailscale mesh · " + ld.ns } + a.Timeline = ld.timeline if ld.run != nil { nodes := atlas.RunNodes(*ld.run) for i := range a.Stages { @@ -71,10 +72,11 @@ func NewHandler() http.Handler { // liveOverlay holds cluster-sourced substrate facts (node specs + namespace line). type liveOverlayData struct { - hosts []atlas.Host - ns string - run *atlas.RunSummary - deploy *atlas.Deploy + hosts []atlas.Host + ns string + run *atlas.RunSummary + deploy *atlas.Deploy + timeline []atlas.RunDot } // live cache: query the cluster at most once per TTL; fall back to the authored @@ -110,6 +112,9 @@ func liveOverlay() liveOverlayData { if r, err := atlas.LatestRunJobs(raw); err == nil { d.run = &r } + if dots, err := atlas.RecentRuns(raw, 12); err == nil { + d.timeline = dots + } } if raw, err := cluster.Deployment(); err == nil { if dep, err := atlas.DeployState(raw); err == nil { diff --git a/internal/web/static/cad-atlas.html b/internal/web/static/cad-atlas.html index 01fe665..896b88d 100644 --- a/internal/web/static/cad-atlas.html +++ b/internal/web/static/cad-atlas.html @@ -52,6 +52,13 @@ .host .k{color:var(--dim);font-size:11px} .host.mesh{border-style:dashed;color:var(--dim)} + .tl{display:none;gap:5px;align-items:center;flex-wrap:wrap; + padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)} + .tl .lbl{color:var(--dim);font-size:11px;letter-spacing:1.5px;margin-right:4px} + .rundot{width:22px;height:16px;border-radius:4px;border:1px solid rgba(0,0,0,.35); + display:inline-flex;align-items:center;justify-content:center; + font-size:9px;color:#08121f;font-weight:600} + .scroll{overflow-x:auto;padding:24px 22px 20px} .track{position:relative;display:flex;align-items:flex-start;min-width:max-content} svg.spine{position:absolute;left:0;top:0;z-index:0;pointer-events:none;overflow:visible} @@ -122,6 +129,7 @@
SUBSTRATE
+
@@ -154,7 +162,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=[]; +let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[]; const track=document.getElementById('track'); let stageEls=[]; @@ -181,6 +189,20 @@ function renderAtlas(){ }); } +function renderTimeline(){ + const tl=document.getElementById('timeline'); + if(!TIMELINE.length){tl.style.display='none';return;} + tl.innerHTML='RECENT RUNS'; + TIMELINE.forEach(d=>{ + const c=d.state==='success'?'var(--green)':(d.state==='failure'?'var(--coral)':'var(--amber)'); + const el=document.createElement('span'); + el.className='rundot';el.style.background=c; + el.title='run #'+d.number+' · '+d.state;el.textContent=d.number; + tl.appendChild(el); + }); + tl.style.display='flex'; +} + /* ---- geometry ---- */ const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'), loopPath=document.getElementById('loopPath'), loopLbl=document.getElementById('loopLbl'), @@ -233,6 +255,7 @@ async function init(){ if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate; 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(data.version) document.getElementById('ver').textContent=data.version; }catch(e){ console.error('atlas: failed to load /api/atlas.json —',e); @@ -243,6 +266,7 @@ async function init(){ return; } renderAtlas(); + renderTimeline(); replay(); } window.addEventListener('load',init);