generated from mathias/template-go-web
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e28dcf69f | ||
|
|
995e428eba | ||
|
|
3d1f76997b |
@@ -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":[
|
||||||
|
|||||||
@@ -53,6 +53,66 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) {
|
|||||||
return hosts, nil
|
return hosts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
|||||||
@@ -98,6 +98,38 @@ func TestNamespaceSummary_CapsWithMore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|||||||
@@ -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
@@ -31,21 +31,63 @@ 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,
|
||||||
// newest first) and returns the newest run with its jobs in pipeline order.
|
// newest first) and returns the newest run with its jobs in pipeline order.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ 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")
|
||||||
|
}
|
||||||
|
|
||||||
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 == "" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,14 @@ func NewHandler() http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ld.deploy != nil {
|
||||||
|
node := atlas.DeployNode(*ld.deploy)
|
||||||
|
for i := range a.Stages {
|
||||||
|
if a.Stages[i].Generate == "deploy-state" {
|
||||||
|
a.Stages[i].Nodes = append([]atlas.Node{node}, 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 +75,8 @@ type liveOverlayData struct {
|
|||||||
hosts []atlas.Host
|
hosts []atlas.Host
|
||||||
ns string
|
ns string
|
||||||
run *atlas.RunSummary
|
run *atlas.RunSummary
|
||||||
|
deploy *atlas.Deploy
|
||||||
|
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 +112,14 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
liveCache = d
|
liveCache = d
|
||||||
return d
|
return d
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user