4 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 1f7c9ab1eb feat(atlas): Phase C — live Flux reconcile status on stage 07
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Has been skipped
Stage 07 now also shows the Flux `apps` Kustomization state — reconciled/failed
+ last-applied revision (main@shortsha) — read in-cluster (new read-only Role in
flux-system). Sits alongside the live deploy node. FluxStatus/FluxNode test-first,
fallback-safe.

Verified: build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:56:42 +02:00
mathiasandClaude Opus 4.8 3e28dcf69f fix(atlas): treat skipped jobs as OK in run aggregate
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Has been skipped
Tag-push runs skip the deploy job (deploy is main-only), so runs with a skipped
job were mislabelled "running" in the timeline. Skipped now counts as completed-
OK; only genuinely in-progress states aggregate to running. Test-first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:48:30 +02:00
mathiasandClaude Opus 4.8 995e428eba feat(atlas): Phase C — recent-runs timeline
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Has been skipped
A strip of the last 12 runs (aggregate pass/fail/running per run) below the
substrate ribbon, coloured, newest-first, run # on hover. Parsed from the same
Gitea /actions/tasks fetch (RecentRuns, test-first; State refactored to share
the aggregate). No new RBAC. Hidden when no live data.

Verified: build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:45:59 +02:00
mathiasandClaude Opus 4.8 3d1f76997b feat(atlas): Phase C — live CD/deploy state on stage 07
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 13s
CD / Detect unsubstituted template (push) Successful in 0s
CD / Deploy via GitOps (push) Has been skipped
Stage 07 now shows the pod's own live deploy state — deployed image tag +
N/M replicas ready, green only when the rollout is fully ready — read from the
k8s Deployment in-cluster (new namespaced deployments-read Role). DeployState/
DeployNode test-first; prepended to the authored CD narrative, fallback-safe.

Verified: build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:39:01 +02:00
10 changed files with 344 additions and 17 deletions
+1 -1
View File
@@ -36,7 +36,7 @@
{"t":"assessor-loop ledger","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]} {"t":"assessor-loop ledger","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]}
]}, ]},
{"no":"STAGE 06","title":"PR → CI","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs","nodes":[]}, {"no":"STAGE 06","title":"PR → CI","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs","nodes":[]},
{"no":"STAGE 07","cls":"cd","title":"CD → pod","path":"Flux GitOps → k3s","nodes":[ {"no":"STAGE 07","cls":"cd","title":"CD → pod","path":"Flux GitOps → k3s","generate":"deploy-state","nodes":[
{"t":"Deploy on green","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]} {"t":"Deploy on green","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]}
]}, ]},
{"no":"STAGE 08","cls":"telos","title":"Loop back","path":"→ TELOS (feedback bus)","nodes":[ {"no":"STAGE 08","cls":"telos","title":"Loop back","path":"→ TELOS (feedback bus)","nodes":[
+124
View File
@@ -53,6 +53,130 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) {
return hosts, nil return hosts, nil
} }
// Flux is the reconciliation state of a Flux Kustomization.
type Flux struct {
Ready bool
Reason string
Revision string
}
// FluxStatus parses a Flux Kustomization object into its reconcile state.
func FluxStatus(kustJSON []byte) (Flux, error) {
var k struct {
Status struct {
Conditions []struct {
Type string `json:"type"`
Status string `json:"status"`
Reason string `json:"reason"`
} `json:"conditions"`
LastAppliedRevision string `json:"lastAppliedRevision"`
} `json:"status"`
}
if err := json.Unmarshal(kustJSON, &k); err != nil {
return Flux{}, fmt.Errorf("parse kustomization: %w", err)
}
f := Flux{Revision: shortRev(k.Status.LastAppliedRevision)}
for _, c := range k.Status.Conditions {
if c.Type == "Ready" {
f.Ready = c.Status == "True"
f.Reason = c.Reason
}
}
return f, nil
}
// FluxNode renders the Flux reconcile state as a stage node.
func FluxNode(f Flux) Node {
if f.Ready {
return Node{
Title: "⟳ Flux · reconciled · " + f.Revision,
Pill: "var(--green)",
Tags: []string{"live · k8s"},
}
}
return Node{
Title: "⟳ Flux · " + f.Reason,
Pill: "var(--coral)",
Tags: []string{"live · k8s"},
}
}
// shortRev turns a Flux revision "main@sha1:<full>" into "main@<short>".
func shortRev(rev string) string {
at := strings.Index(rev, "@")
if at < 0 {
return rev
}
branch, sha := rev[:at], rev[at+1:]
if c := strings.LastIndex(sha, ":"); c >= 0 {
sha = sha[c+1:]
}
if len(sha) > 7 {
sha = sha[:7]
}
return branch + "@" + sha
}
// Deploy is the live state of a Kubernetes Deployment.
type Deploy struct {
Image string
Ready int
Desired int
}
// Tag is the image tag (substring after the last ":").
func (d Deploy) Tag() string {
if i := strings.LastIndex(d.Image, ":"); i >= 0 {
return d.Image[i+1:]
}
return d.Image
}
// DeployState parses a Kubernetes Deployment object into its live state.
func DeployState(deployJSON []byte) (Deploy, error) {
var dep struct {
Spec struct {
Replicas int `json:"replicas"`
Template struct {
Spec struct {
Containers []struct {
Image string `json:"image"`
} `json:"containers"`
} `json:"spec"`
} `json:"template"`
} `json:"spec"`
Status struct {
ReadyReplicas int `json:"readyReplicas"`
} `json:"status"`
}
if err := json.Unmarshal(deployJSON, &dep); err != nil {
return Deploy{}, fmt.Errorf("parse deployment: %w", err)
}
d := Deploy{Ready: dep.Status.ReadyReplicas, Desired: dep.Spec.Replicas}
if len(dep.Spec.Template.Spec.Containers) > 0 {
d.Image = dep.Spec.Template.Spec.Containers[0].Image
}
return d, nil
}
// DeployNode renders the live deploy state as a stage node (green when the
// rollout is fully ready, amber otherwise).
func DeployNode(d Deploy) Node {
pill := "var(--amber)"
if d.Desired > 0 && d.Ready == d.Desired {
pill = "var(--green)"
}
img := d.Image
if i := strings.LastIndex(img, "/"); i >= 0 {
img = img[i+1:] // drop registry host
}
return Node{
Title: fmt.Sprintf("◆ deployed · %s · %d/%d ready", img, d.Ready, d.Desired),
Pill: pill,
Tags: []string{"live · k8s"},
}
}
// NamespaceSummary parses a Kubernetes `/api/v1/namespaces` list into a compact // NamespaceSummary parses a Kubernetes `/api/v1/namespaces` list into a compact
// "ns: a · b · c" line for the substrate, dropping system namespaces, sorting, // "ns: a · b · c" line for the substrate, dropping system namespaces, sorting,
// and capping the count (with "+N more" when it overflows). // and capping the count (with "+N more" when it overflows).
+60
View File
@@ -98,6 +98,66 @@ func TestNamespaceSummary_CapsWithMore(t *testing.T) {
} }
} }
func TestFluxStatus_ParsesReadyReasonAndShortRevision(t *testing.T) {
k := []byte(`{"status":{
"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"Applied revision: main@sha1:7a51d1d13944"}],
"lastAppliedRevision":"main@sha1:7a51d1d13944c481b04fc434d9a2aca2a25632e4"}}`)
f, err := atlas.FluxStatus(k)
if err != nil {
t.Fatalf("FluxStatus: %v", err)
}
if !f.Ready || f.Reason != "ReconciliationSucceeded" || f.Revision != "main@7a51d1d" {
t.Fatalf("flux = %+v", f)
}
}
func TestFluxNode_GreenWhenReadyCoralWhenNot(t *testing.T) {
ready := atlas.FluxNode(atlas.Flux{Ready: true, Reason: "ReconciliationSucceeded", Revision: "main@7a51d1d"})
if ready.Pill != "var(--green)" {
t.Fatalf("ready pill = %q", ready.Pill)
}
if ready.Title != "⟳ Flux · reconciled · main@7a51d1d" {
t.Fatalf("title = %q", ready.Title)
}
notReady := atlas.FluxNode(atlas.Flux{Ready: false, Reason: "BuildFailed"})
if notReady.Pill != "var(--coral)" {
t.Fatalf("not-ready pill = %q", notReady.Pill)
}
}
func TestDeployState_ParsesImageAndReplicas(t *testing.T) {
dep := []byte(`{"spec":{"replicas":2,"template":{"spec":{"containers":[
{"name":"cad-atlas","image":"localhost:5000/cad-atlas:3ff922a"}]}}},
"status":{"readyReplicas":1,"replicas":2}}`)
d, err := atlas.DeployState(dep)
if err != nil {
t.Fatalf("DeployState: %v", err)
}
if d.Image != "localhost:5000/cad-atlas:3ff922a" || d.Ready != 1 || d.Desired != 2 {
t.Fatalf("deploy = %+v", d)
}
if d.Tag() != "3ff922a" {
t.Fatalf("tag = %q, want 3ff922a", d.Tag())
}
}
func TestDeployNode_GreenOnlyWhenFullyReady(t *testing.T) {
ready := atlas.DeployNode(atlas.Deploy{Image: "localhost:5000/cad-atlas:abc", Ready: 1, Desired: 1})
if ready.Pill != "var(--green)" {
t.Fatalf("ready pill = %q, want green", ready.Pill)
}
if ready.Title != "◆ deployed · cad-atlas:abc · 1/1 ready" {
t.Fatalf("title = %q", ready.Title)
}
rolling := atlas.DeployNode(atlas.Deploy{Image: "x/cad-atlas:def", Ready: 0, Desired: 1})
if rolling.Pill != "var(--amber)" {
t.Fatalf("rolling pill = %q, want amber", rolling.Pill)
}
}
func TestHostsFromNodes_ErrorsOnBadJSON(t *testing.T) { func TestHostsFromNodes_ErrorsOnBadJSON(t *testing.T) {
if _, err := atlas.HostsFromNodes([]byte("{not json")); err == nil { if _, err := atlas.HostsFromNodes([]byte("{not json")); err == nil {
t.Fatal("expected error on bad JSON, got nil") t.Fatal("expected error on bad JSON, got nil")
+1
View File
@@ -38,6 +38,7 @@ type Atlas struct {
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
Substrate []Host `json:"substrate"` Substrate []Host `json:"substrate"`
NS string `json:"ns,omitempty"` NS string `json:"ns,omitempty"`
Timeline []RunDot `json:"timeline,omitempty"`
Stages []Stage `json:"stages"` Stages []Stage `json:"stages"`
} }
+49 -7
View File
@@ -31,20 +31,62 @@ type RunSummary struct {
// State aggregates the jobs: failure if any failed, running if any not yet // State aggregates the jobs: failure if any failed, running if any not yet
// succeeded, else success. // succeeded, else success.
func (s RunSummary) State() string { func (s RunSummary) State() string {
allSucceeded := true return aggregateState(s.Jobs)
for _, j := range 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 {
pending := false
for _, j := range jobs {
switch j.State() { switch j.State() {
case "failure", "cancelled", "error": case "failure", "cancelled", "error":
return "failure" return "failure"
case "success": case "success", "skipped": // completed OK — skipped (e.g. deploy on a tag push) doesn't block
default: default:
allSucceeded = false pending = true // running / in_progress / waiting / queued / unknown
} }
} }
if allSucceeded { if pending {
return "success"
}
return "running" return "running"
}
return "success"
} }
// LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries, // LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries,
+34
View File
@@ -65,6 +65,40 @@ 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 TestRunState_SkippedJobsCountAsOK(t *testing.T) {
// A tag-push run skips the deploy job; the run still succeeded.
s := atlas.RunSummary{Jobs: []atlas.Job{
{Status: "skipped"}, {Status: "success"}, {Status: "success"},
}}
if s.State() != "success" {
t.Fatalf("skipped+success run state = %q, want success", s.State())
}
}
func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) { func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) {
if _, err := atlas.LatestRunJobs([]byte(`{"workflow_runs":[]}`)); err == nil { if _, err := atlas.LatestRunJobs([]byte(`{"workflow_runs":[]}`)); err == nil {
t.Fatal("expected error on empty, got nil") t.Fatal("expected error on empty, got nil")
+11
View File
@@ -24,6 +24,17 @@ func Nodes() ([]byte, error) { return get("/api/v1/nodes") }
// Namespaces returns the raw /api/v1/namespaces JSON from the in-cluster API server. // Namespaces returns the raw /api/v1/namespaces JSON from the in-cluster API server.
func Namespaces() ([]byte, error) { return get("/api/v1/namespaces") } func Namespaces() ([]byte, error) { return get("/api/v1/namespaces") }
// Deployment returns the raw JSON for the cad-atlas Deployment (its own live state).
func Deployment() ([]byte, error) {
return get("/apis/apps/v1/namespaces/cad-atlas/deployments/cad-atlas")
}
// FluxKustomization returns the raw JSON for the Flux `apps` Kustomization that
// reconciles this repo's manifests.
func FluxKustomization() ([]byte, error) {
return get("/apis/kustomize.toolkit.fluxcd.io/v1/namespaces/flux-system/kustomizations/apps")
}
func get(path string) ([]byte, error) { func get(path string) ([]byte, error) {
host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT") host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
if host == "" || port == "" { if host == "" || port == "" {
+1 -1
View File
@@ -21,7 +21,7 @@ func base() string {
// Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first). // Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first).
func Runs() ([]byte, error) { 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} client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client
if err != nil { if err != nil {
+31
View File
@@ -47,6 +47,7 @@ func NewHandler() http.Handler {
if ld.ns != "" { if ld.ns != "" {
a.NS = "Tailscale mesh · " + ld.ns a.NS = "Tailscale mesh · " + ld.ns
} }
a.Timeline = ld.timeline
if ld.run != nil { if ld.run != nil {
nodes := atlas.RunNodes(*ld.run) nodes := atlas.RunNodes(*ld.run)
for i := range a.Stages { for i := range a.Stages {
@@ -55,6 +56,20 @@ func NewHandler() http.Handler {
} }
} }
} }
if ld.deploy != nil || ld.flux != nil {
var live []atlas.Node
if ld.deploy != nil {
live = append(live, atlas.DeployNode(*ld.deploy))
}
if ld.flux != nil {
live = append(live, atlas.FluxNode(*ld.flux))
}
for i := range a.Stages {
if a.Stages[i].Generate == "deploy-state" {
a.Stages[i].Nodes = append(live, a.Stages[i].Nodes...)
}
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(a) _ = json.NewEncoder(w).Encode(a)
}) })
@@ -66,6 +81,9 @@ type liveOverlayData struct {
hosts []atlas.Host hosts []atlas.Host
ns string ns string
run *atlas.RunSummary run *atlas.RunSummary
deploy *atlas.Deploy
flux *atlas.Flux
timeline []atlas.RunDot
} }
// live cache: query the cluster at most once per TTL; fall back to the authored // live cache: query the cluster at most once per TTL; fall back to the authored
@@ -101,6 +119,19 @@ func liveOverlay() liveOverlayData {
if r, err := atlas.LatestRunJobs(raw); err == nil { if r, err := atlas.LatestRunJobs(raw); err == nil {
d.run = &r 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 {
d.deploy = &dep
}
}
if raw, err := cluster.FluxKustomization(); err == nil {
if f, err := atlas.FluxStatus(raw); err == nil {
d.flux = &f
}
} }
liveCache = d liveCache = d
return d return d
+25 -1
View File
@@ -52,6 +52,13 @@
.host .k{color:var(--dim);font-size:11px} .host .k{color:var(--dim);font-size:11px}
.host.mesh{border-style:dashed;color:var(--dim)} .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} .scroll{overflow-x:auto;padding:24px 22px 20px}
.track{position:relative;display:flex;align-items:flex-start;min-width:max-content} .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} svg.spine{position:absolute;left:0;top:0;z-index:0;pointer-events:none;overflow:visible}
@@ -122,6 +129,7 @@
</header> </header>
<div class="substrate" id="substrate"><span class="lbl mono">SUBSTRATE</span></div> <div class="substrate" id="substrate"><span class="lbl mono">SUBSTRATE</span></div>
<div class="tl mono" id="timeline"></div>
<div class="scroll"> <div class="scroll">
<div class="track" id="track"> <div class="track" id="track">
@@ -154,7 +162,7 @@
// stage from the live cd.yml + substrate from the live cluster). These start // 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 // empty and are filled by init()'s fetch; on failure the page shows an error
// banner rather than stale inline data. // banner rather than stale inline data.
let SUBSTRATE=[], NS="", STAGES=[]; let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[];
const track=document.getElementById('track'); const track=document.getElementById('track');
let stageEls=[]; 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 ---- */ /* ---- geometry ---- */
const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'), const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'),
loopPath=document.getElementById('loopPath'), loopLbl=document.getElementById('loopLbl'), loopPath=document.getElementById('loopPath'), loopLbl=document.getElementById('loopLbl'),
@@ -233,6 +255,7 @@ async function init(){
if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate; if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate;
if(typeof data.ns==='string') NS=data.ns; if(typeof data.ns==='string') NS=data.ns;
if(Array.isArray(data.stages)) STAGES=data.stages; 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; if(data.version) document.getElementById('ver').textContent=data.version;
}catch(e){ }catch(e){
console.error('atlas: failed to load /api/atlas.json —',e); console.error('atlas: failed to load /api/atlas.json —',e);
@@ -243,6 +266,7 @@ async function init(){
return; return;
} }
renderAtlas(); renderAtlas();
renderTimeline();
replay(); replay();
} }
window.addEventListener('load',init); window.addEventListener('load',init);