generated from mathias/template-go-web
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
995e428eba |
@@ -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
|
||||
|
||||
+43
-1
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 @@
|
||||
</header>
|
||||
|
||||
<div class="substrate" id="substrate"><span class="lbl mono">SUBSTRATE</span></div>
|
||||
<div class="tl mono" id="timeline"></div>
|
||||
|
||||
<div class="scroll">
|
||||
<div class="track" id="track">
|
||||
@@ -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='<span class="lbl">RECENT RUNS</span>';
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user