generated from mathias/template-go-web
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d992ca4a01 | ||
|
|
5048450b79 | ||
|
|
1f7c9ab1eb | ||
|
|
3e28dcf69f | ||
|
|
995e428eba | ||
|
|
3d1f76997b | ||
|
|
3ff922a4d5 | ||
|
|
02a23a02dd | ||
|
|
633ba153f2 | ||
|
|
39fd9b9adc | ||
|
|
74dffc1ff3 | ||
|
|
a9c72d6ca8 | ||
|
|
c3fda9b26b | ||
|
|
6a3ea77af5 | ||
|
|
68adfe7e01 |
+10
-1
@@ -19,8 +19,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- id: detect
|
- id: detect
|
||||||
|
# Detect an UNSUBSTITUTED template by the leftover __PLACEHOLDER__ tokens.
|
||||||
|
# Must not grep for the substituted module path — that pattern is itself
|
||||||
|
# substituted at generate time, so it would match every real child repo's
|
||||||
|
# own go.mod and skip all CI forever (template-go-web bug, see repo #).
|
||||||
run: |
|
run: |
|
||||||
if grep -q 'git.d-ma.be/mathias/cad-atlas' go.mod; then
|
if grep -qE '__[A-Z_]+__' go.mod; then
|
||||||
echo "is_template=true" >> "$GITHUB_OUTPUT"
|
echo "is_template=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "is_template=false" >> "$GITHUB_OUTPUT"
|
echo "is_template=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -58,18 +62,23 @@ jobs:
|
|||||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # full history + tags so `git describe` sees the SemVer tag
|
||||||
|
|
||||||
- name: Derive image tags
|
- name: Derive image tags
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
SHA=$(git rev-parse --short HEAD)
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
VERSION=$(git describe --tags --always --dirty)
|
||||||
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
|
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and push to local registry
|
- name: Build and push to local registry
|
||||||
run: |
|
run: |
|
||||||
REGISTRY="localhost:5000"
|
REGISTRY="localhost:5000"
|
||||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
||||||
buildah build \
|
buildah build \
|
||||||
|
--build-arg VERSION="${{ steps.meta.outputs.version }}" \
|
||||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||||
-t ${REF} \
|
-t ${REF} \
|
||||||
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
|
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,10 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
|
|||||||
COPY go.mod ./
|
COPY go.mod ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN templ generate && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/cad-atlas
|
ARG VERSION=dev
|
||||||
|
RUN templ generate && CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X git.d-ma.be/mathias/cad-atlas/internal/version.Value=${VERSION}" \
|
||||||
|
-o /out/app ./cmd/cad-atlas
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
COPY --from=build /out/app /app
|
COPY --from=build /out/app /app
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath
|
|||||||
|---|--------|--------|----------|
|
|---|--------|--------|----------|
|
||||||
| G1 | Repo born from template on Gitea, dispatch-eligible | ✅ | `create_project_from_template`, `.dispatch-allow`, dispatch-allowlisted |
|
| G1 | Repo born from template on Gitea, dispatch-eligible | ✅ | `create_project_from_template`, `.dispatch-allow`, dispatch-allowlisted |
|
||||||
| G2 | Self-contained context + brain deep-links + external refs | ✅ | `.context/PROJECT.md`, `README.md` |
|
| G2 | Self-contained context + brain deep-links + external refs | ✅ | `.context/PROJECT.md`, `README.md` |
|
||||||
| G3 | Green CI on main: build · vet · lint · test (TDD) | ✅ | CI runs 16–17 green; `internal/web/handler_test.go` |
|
| G3 | Green CI on main: build · vet · lint · test (TDD) | ✅ | Genuinely green from run 19 (commit 68adfe7). NOTE: runs 16–18 were *hollow* — a template guard bug (template-go-web#9) skipped every job; fixed here. `internal/web/handler_test.go` |
|
||||||
| G4 | Genesis learning persisted to brain, linked from repo | ✅ | `wiki/homelab/decisions/cad-atlas-audit-chain-is-viz-data.md` |
|
| G4 | Genesis learning persisted to brain, linked from repo | ✅ | `wiki/homelab/decisions/cad-atlas-audit-chain-is-viz-data.md` |
|
||||||
| G5 | Discovered findings filed as tracker issues | ✅ | template-go-web#8, local-dev#20 |
|
| G5 | Discovered findings filed as tracker issues | ✅ | template-go-web#8, local-dev#20 |
|
||||||
| G6 | SemVer tag marks the milestone (closing act) | ✅ | `v0.1.0` |
|
| G6 | SemVer tag marks the milestone (closing act) | ✅ | `v0.1.0` |
|
||||||
@@ -26,6 +26,13 @@ Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath
|
|||||||
| S2 | Oath covered in the viz (stages 03 + 06) | ✅ | var-go Oath nodes in the atlas |
|
| S2 | Oath covered in the viz (stages 03 + 06) | ✅ | var-go Oath nodes in the atlas |
|
||||||
| S3 | `var-go/oath` enforces cad-atlas's own PRs | ⏸ **deferred → #1** | var-go v1 candidate is a toy self-test; module not cross-repo consumable. See honesty rule. |
|
| S3 | `var-go/oath` enforces cad-atlas's own PRs | ⏸ **deferred → #1** | var-go v1 candidate is a toy self-test; module not cross-repo consumable. See honesty rule. |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Live at **https://atlas.d-ma.be** (Authentik forward-auth — authed users only) on koala k3s:
|
||||||
|
namespace `cad-atlas`, 1 replica, `cad-atlas:80 → :8080` (manifests in `mathias/infra`
|
||||||
|
`k3s/apps/cad-atlas/`, reconciled by Flux). CI `deploy` job **auto-deploys** on every main push —
|
||||||
|
`INFRA_DEPLOY_KEY` (write deploy-key `cad-atlas-ci` on infra) is wired (#2 done).
|
||||||
|
|
||||||
## The honesty rule
|
## The honesty rule
|
||||||
|
|
||||||
A clause blocked by an external dependency is **descoped and tracked, never marked satisfied** —
|
A clause blocked by an external dependency is **descoped and tracked, never marked satisfied** —
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# CAD Atlas — Fresh-Eyes UX Review
|
||||||
|
|
||||||
|
Reviewer role: fresh-eyes UX (not implementer). This document critiques the copy and
|
||||||
|
information architecture of the "From Signal to Pod" atlas and specifies the
|
||||||
|
progressive-disclosure layer for the coming sprint. It does not change code.
|
||||||
|
|
||||||
|
Sources reviewed:
|
||||||
|
- `internal/atlas/atlas.json` (authored stages + nodes)
|
||||||
|
- `internal/web/handler.go` (which stages get live data overlaid)
|
||||||
|
- `.context/PROJECT.md` (ground-truth meaning of each stage/gate)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Diagnosis — why the current atlas is hard for a naive viewer
|
||||||
|
|
||||||
|
The atlas is written by the person who built the pipeline, for the person who built the
|
||||||
|
pipeline. Almost every node names a **mechanism** (`Ed25519 admission controller`,
|
||||||
|
`var-go Oath`, `dma-cli`, `assessor-loop ledger`, `agentsquad`, `ISC`, `TELOS`) rather
|
||||||
|
than the **thing that happens to a piece of work**. A compliance officer or a new
|
||||||
|
engineer cannot answer the two questions they actually have: *"what is happening to the
|
||||||
|
work at this step?"* and *"why does it move to the next step?"*
|
||||||
|
|
||||||
|
The second question is completely unanswered. The atlas renders nine stages connected by
|
||||||
|
arrows, but the arrows carry **zero copy**. There is no statement of what has to be true
|
||||||
|
for work to advance — which is exactly where the interesting governance lives (a human
|
||||||
|
sign-off at 04, a green CI gate at 06, an integrity check at 03). The pipeline's whole
|
||||||
|
selling point is "auditable, gated flow," yet the gates between stages are invisible.
|
||||||
|
A viewer sees a row of jargon boxes and an implied left-to-right drift, with no sense of
|
||||||
|
what earns each hop.
|
||||||
|
|
||||||
|
Two smaller aggravators: (a) the reel is dense — 20+ nodes, colour-coded pills whose
|
||||||
|
meaning is never keyed, and Swedish-homelab proper nouns (koala, iguana, flamingo) that a
|
||||||
|
stakeholder can't decode; (b) Stage 06 is authored-empty (it's generated live from CI),
|
||||||
|
so on a cold/offline load that column can read as "nothing happens here," which is the
|
||||||
|
opposite of the truth — CI is a governance gate.
|
||||||
|
|
||||||
|
The fix is not to dumb it down. It is to make **plain-language the default layer** — a
|
||||||
|
one-line "what happens + why" per stage, and a labelled "what must be true to advance"
|
||||||
|
per arrow — and demote today's precise, correct technical copy to an **on-demand layer**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Per-stage plain-language map (00–08)
|
||||||
|
|
||||||
|
| stage | plain_title | plain_what (one jargon-free sentence) | keep_technical (on-demand) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **00 Signals** | Notice what's happening | New ideas and developments worth reacting to are collected — mostly an automated daily/weekly scan of AI news, plus things saved by hand. | "Signals → mathias/signals"; Applied AI Radar (Tier-1 daily / Tier-2 weekly), verified-primary bar; brain capture; aspirational inbox surfaces (not built). |
|
||||||
|
| **01 TELOS** | Why we're here | The mission, goals, and problems we're actually trying to solve live here — every piece of work downstream has to trace back to one of these goals. | "TELOS — intention substrate", `wiki/telos/`, `brain_query wing=telos`. |
|
||||||
|
| **02 Strategic session** | Think it through | A human and AI models work out *what* to do and *why*, debating hard calls and writing down the decision and what "done" will mean. | "Strategic session" — claude.ai frontier + brain MCP; ADRs/specs; ISC acceptance criteria; LLM Council (fan-out → anonymous cross-review → chairman synth); Autoresearch Council. |
|
||||||
|
| **03 Spec → Gitea issue** | Write the work order | The decision is turned into a precise, self-contained work order that an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal. | "Spec → Gitea issue" — binary ISC, risk tier LOW/MED/HIGH, reg-risk assessment, no open human deps; Ed25519 admission controller (#36); var-go Oath (single fenced block, fail-closed). |
|
||||||
|
| **04 Human dispatch gate** | Human says go | A person reviews the work order and its risk and decides whether to release it — this is the one and only checkpoint where work does not move on its own. | "Human dispatch gate — the only checkpoint"; ratify plan + risk tier; Session-Dispatch bridge (claude.ai MCP → `workflow_run_trigger` → `cad-dispatch.yml` → agentsquad); dispatch-allow eligibility. |
|
||||||
|
| **05 Execute · agentsquad** | Agents do the work | AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail. | "Execute · agentsquad" on koala; Task API (`POST /tasks`); executor+reviewer loop (ADK Go + LiteLLM, reviewer on distinct tier); dma-cli routing + 3-layer scope guardrail; assessor-loop attestation ledger + brain session_log. |
|
||||||
|
| **06 PR → CI** | Automatic quality checks | The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue. | "PR → CI" — Gitea Actions `cd.yml`; go test/vet/lint/govulncheck; **var-go/oath gate** (correctness floor over the reviewer, anti-rubber-stamp #55). *Nodes generated live from the latest CI run.* |
|
||||||
|
| **07 CD → pod** | Ship it | Once everything is green, the change is deployed automatically to the live server — with the rule that merging code alone doesn't ship it; the release has to be pointed at the new version. | "CD → pod" — Flux GitOps → k3s on koala; "push ≠ deploy: bump tag in mathias/infra"; ntfy on deploy. *Deploy + Flux state overlaid live.* |
|
||||||
|
| **08 Loop back** | Did it work? | The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped. | "Loop back → TELOS (feedback bus)"; session_log + attestation → brain; outcome scored vs originating goal; arc partly manual (improvement target). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Per-node plain restatements
|
||||||
|
|
||||||
|
The existing `d` text stays as the **technical detail layer**. Each `plain` line below is
|
||||||
|
the jargon-free default. Kept accurate to PROJECT.md.
|
||||||
|
|
||||||
|
**Stage 00 — Signals**
|
||||||
|
- *Applied AI Radar* → **"An automated scan reads AI news every day (and deeper every week) and keeps only claims backed by a real paper, benchmark, code, or named lab."**
|
||||||
|
- *Manual capture* → **"Anything interesting spotted by hand gets saved into the same inbox."**
|
||||||
|
- *Aspirational surfaces* → **"Planned-but-not-built: sending ideas in by Telegram, voice, or a URL."** (mark clearly as a gap / not yet real.)
|
||||||
|
|
||||||
|
**Stage 01 — TELOS**
|
||||||
|
- *Intention substrate* → **"The master list of mission, goals, problems, and current status — the yardstick everything downstream is measured against."**
|
||||||
|
|
||||||
|
**Stage 02 — Strategic session**
|
||||||
|
- *Design · ADRs · specs* → **"A human and a top-tier AI model figure out the approach and write down the decision plus what a finished result must prove."**
|
||||||
|
- *LLM Council* → **"For hard calls, several AI models answer independently, anonymously critique each other, and a 'chair' model synthesises one verdict — reduces any single model's bias."**
|
||||||
|
- *Autoresearch Council* → **"A parallel version of the same review that vets research findings before they're allowed through."**
|
||||||
|
|
||||||
|
**Stage 03 — Spec → Gitea issue**
|
||||||
|
- *Contract enforced* → **"The work order must have a clear pass/fail test, a risk rating, a regulatory-risk note, and no unfinished human dependencies before it counts as agent-ready."**
|
||||||
|
- *Admission controller* → **"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it."**
|
||||||
|
- *var-go Oath* → **"A machine-checkable 'definition of done' is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec."**
|
||||||
|
|
||||||
|
**Stage 04 — Human dispatch gate**
|
||||||
|
- *Human triggers execution* → **"A person confirms the plan and its risk level, then releases the work — nothing runs until they do."**
|
||||||
|
- *Session-Dispatch bridge* → **"The approval flips a switch that hands the signed work order over to the agents to start execution."**
|
||||||
|
|
||||||
|
**Stage 05 — Execute · agentsquad**
|
||||||
|
- *Task API* → **"A request kicks off a job and hands back an id you can poll for progress."**
|
||||||
|
- *Executor + reviewer loop* → **"One agent does the work; a second, independent agent on a different model reviews it — so nothing marks its own homework."**
|
||||||
|
- *dma-cli · routing + scope* → **"A router sends each agent to the right AI backend and enforces what it is and isn't allowed to touch, with a confirmation gate as a guardrail."**
|
||||||
|
- *assessor-loop ledger* → **"Every step is recorded in a tamper-evident log so the whole run can be audited afterwards."**
|
||||||
|
|
||||||
|
**Stage 06 — PR → CI** *(nodes generated live from the latest CI run — no authored nodes)*
|
||||||
|
- Live jobs render here; the plain framing for the column is: **"Automated tests and safety checks run on the proposed change, including a check that it truly satisfies the work order — only a clean pass moves on."**
|
||||||
|
|
||||||
|
**Stage 07 — CD → pod**
|
||||||
|
- *Deploy on green* → **"When all checks pass, the release system rolls the new version onto the live server automatically — but only once the release is pointed at that version (merging code alone doesn't ship it)."** *(live deploy + Flux status also shown.)*
|
||||||
|
|
||||||
|
**Stage 08 — Loop back**
|
||||||
|
- *Close the loop* → **"The outcome is scored against the goal that started it and written back to the mission board, so future planning learns from what actually shipped."**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Transitions — the key deliverable
|
||||||
|
|
||||||
|
For each arrow: *what moves the work forward, and what must be true for it to advance.*
|
||||||
|
These should be rendered **on the arrows themselves** (see §5). Today they are blank.
|
||||||
|
|
||||||
|
- **00 → 01 — "Does it matter to us?"**
|
||||||
|
A raw signal only advances if it connects to something we actually care about. Most
|
||||||
|
captured signals stop here; the few that touch the mission get pulled up against a goal.
|
||||||
|
|
||||||
|
- **01 → 02 — "Worth a session?"**
|
||||||
|
A goal or problem on the board becomes the seed for a design session when it's decided
|
||||||
|
it's worth working on now. The goal is the input the session must trace back to.
|
||||||
|
|
||||||
|
- **02 → 03 — "Decision reached."**
|
||||||
|
Once the debate converges on a decision (and what "done" will mean), it advances only
|
||||||
|
when that thinking is written down as a concrete, testable specification — not while
|
||||||
|
the answer is still open.
|
||||||
|
|
||||||
|
- **03 → 04 — "Order written, sealed, agent-ready."**
|
||||||
|
Work advances to the gate only when the spec is a complete contract: a pass/fail test, a
|
||||||
|
risk tier, a regulatory note, no open human dependencies, one embedded Oath, and a valid
|
||||||
|
cryptographic signature. A malformed or unsigned order fails closed and does not reach
|
||||||
|
the gate.
|
||||||
|
|
||||||
|
- **04 → 05 — "A human said go."**
|
||||||
|
This is the hard stop. Nothing crosses automatically. A person must review the plan and
|
||||||
|
risk and explicitly release it, and the repo must be on the allow-list, before any agent
|
||||||
|
starts. This is the single human checkpoint in the whole pipeline.
|
||||||
|
|
||||||
|
- **05 → 06 — "Agents produced a change."**
|
||||||
|
Work advances when the agents finish and open a proposed change (a PR) with its audit
|
||||||
|
log attached. Until there's a concrete change to test, nothing moves.
|
||||||
|
|
||||||
|
- **06 → 07 — "All checks green."**
|
||||||
|
The change advances only if every automated check passes — tests, linters, security
|
||||||
|
scan, **and** the Oath check proving it meets the original work order. Any red gate stops
|
||||||
|
it here; a passing reviewer is not enough to override a failed Oath.
|
||||||
|
|
||||||
|
- **07 → 08 — "It's live."**
|
||||||
|
Once the new version is actually running on the server, the deployed outcome becomes the
|
||||||
|
input to scoring. Advancing means "shipped and observable," not just "merged."
|
||||||
|
|
||||||
|
- **08 → TELOS (feedback bus, dashed) — "What did we learn?"**
|
||||||
|
The scored outcome flows back into the mission board so goals, problems, and priorities
|
||||||
|
update. This is the loop that makes the pipeline a cycle rather than a line. Note per
|
||||||
|
PROJECT.md this arc is **partly manual today** and is an explicit improvement target —
|
||||||
|
the dashed styling should read as "aspirational / not fully automated," not just decorative.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Progressive-disclosure recommendations
|
||||||
|
|
||||||
|
**Default (Plain) layer — what everyone sees on load:**
|
||||||
|
- Each stage column shows: the **plain_title** as the headline, the technical title as a
|
||||||
|
smaller subtitle, and the one-line **plain_what** directly under it.
|
||||||
|
- Each node shows its **plain** one-liner as the primary text. The current `d` string is
|
||||||
|
hidden by default.
|
||||||
|
- Each arrow shows a short **transition label** (the bolded phrase from §4, e.g. "A human
|
||||||
|
said go", "All checks green") — this is the single biggest comprehension win and must
|
||||||
|
ship in the default layer, not behind a toggle.
|
||||||
|
|
||||||
|
**On hover / expand (per node):**
|
||||||
|
- Reveal the technical `d` text, the `tags`, and the pill's meaning.
|
||||||
|
- On the arrow, hovering expands the short label into the full "what must be true to
|
||||||
|
advance" sentence from §4.
|
||||||
|
|
||||||
|
**Plain ⇄ Technical toggle (global):**
|
||||||
|
- A single top-level switch, defaulting to **Plain**. Persist the choice (localStorage).
|
||||||
|
- Plain: plain_title headline, plain_what, plain node lines, short arrow labels. Proper
|
||||||
|
nouns (koala/iguana/agentsquad/TELOS) suppressed or shown only as a footnote.
|
||||||
|
- Technical: today's exact copy — titles, `d` strings, tags, substrate host specs — i.e.
|
||||||
|
the atlas as it exists now. Nothing is lost; the current view becomes "Technical."
|
||||||
|
- The toggle should crossfade in place, not reflow the whole layout, so a viewer can flip
|
||||||
|
back and forth and map plain↔technical on the same node.
|
||||||
|
|
||||||
|
**Visual cues for the currently-bare transitions:**
|
||||||
|
- Give every arrow a **label chip** sitting on the spine. Gate arrows (04→05 human, 06→07
|
||||||
|
CI) get a distinct treatment — a lock/shield glyph and a stronger colour — because those
|
||||||
|
are the governance moments the whole atlas exists to show.
|
||||||
|
- Make the **08 → TELOS feedback bus** visibly different (dashed + "partly manual" tag) so
|
||||||
|
its aspirational status is honest, matching PROJECT.md's dogfooding-honesty discipline.
|
||||||
|
- Add a small, always-visible **legend** keying the pill colours and the three gate types
|
||||||
|
(integrity / eligibility / correctness), since colour is currently unexplained.
|
||||||
|
- For **Stage 06** (authored-empty, generated live): when no live CI data is present, show
|
||||||
|
the plain_what placeholder ("Automated tests and safety checks run…") rather than an
|
||||||
|
empty column, so it never reads as "nothing happens here."
|
||||||
|
|
||||||
|
**Three-gate overlay (stretch, high value for the compliance audience):**
|
||||||
|
- A "show governance gates" toggle that highlights the three orthogonal gates on top of
|
||||||
|
the pipeline: integrity (03, Ed25519), eligibility (04/05, dispatch-allow), correctness
|
||||||
|
(06, Oath). This directly serves the "audit chain is the viz data" thesis for a
|
||||||
|
compliance/exec viewer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Prioritized punch list (top 8 by comprehension impact)
|
||||||
|
|
||||||
|
1. **Label every arrow with a plain "what must be true to advance" phrase** (§4). Biggest
|
||||||
|
miss, biggest win — turns a row of boxes into a story of gated flow. Default layer.
|
||||||
|
2. **Add a plain_what one-liner per stage** as the default column copy (§2), with the
|
||||||
|
technical title demoted to subtitle.
|
||||||
|
3. **Ship the Plain ⇄ Technical global toggle, defaulting to Plain**, persisting choice;
|
||||||
|
current copy becomes the Technical view (nothing thrown away).
|
||||||
|
4. **Rewrite node primary text to the plain lines** (§3); move existing `d` to hover/expand.
|
||||||
|
5. **Visually distinguish the two real gates (04 human, 06 CI)** with lock/shield glyphs and
|
||||||
|
stronger colour so the checkpoints read as checkpoints.
|
||||||
|
6. **Add a legend** keying pill colours and the three gate types — colour currently carries
|
||||||
|
meaning nobody can decode.
|
||||||
|
7. **Fix the Stage-06 empty-column problem**: show a plain placeholder when live CI data is
|
||||||
|
absent, so the CI gate never looks like a no-op.
|
||||||
|
8. **Make the 08→TELOS feedback bus honestly aspirational** (dashed + "partly manual" tag),
|
||||||
|
and suppress homelab proper nouns (koala/iguana/flamingo/agentsquad/TELOS) in Plain mode,
|
||||||
|
surfacing them only in Technical or a footnote.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Package cadatlas embeds module-root build artifacts that live outside any
|
||||||
|
// subpackage directory. go:embed cannot reach parent directories, so the CI
|
||||||
|
// workflow — which the atlas parses to generate its CI/CD stage from the real
|
||||||
|
// pipeline — is embedded here at the module root.
|
||||||
|
package cadatlas
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// CDWorkflow is the repo's own Gitea Actions workflow, embedded at build time.
|
||||||
|
//
|
||||||
|
//go:embed .gitea/workflows/cd.yml
|
||||||
|
var CDWorkflow []byte
|
||||||
@@ -3,3 +3,5 @@ module git.d-ma.be/mathias/cad-atlas
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require github.com/a-h/templ v0.3.1020
|
require github.com/a-h/templ v0.3.1020
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|||||||
@@ -2,3 +2,7 @@ github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
|||||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"substrate": [
|
||||||
|
{"n":"koala","k":"RTX 5070 · k3s control-plane · Gitea · LiteLLM :30401 · llama-swap :31234 · searxng"},
|
||||||
|
{"n":"iguana","k":"M2 Ultra · Ollama / mlx"},
|
||||||
|
{"n":"flamingo","k":"daily driver · ~/dev"},
|
||||||
|
{"n":"piguard","k":"NGINX reverse-proxy · ntfy"}
|
||||||
|
],
|
||||||
|
"ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council",
|
||||||
|
"stages": [
|
||||||
|
{"no":"STAGE 00","title":"Signals","plain_title":"Notice what's happening","plain":"New ideas and developments worth reacting to are collected — mostly an automated daily/weekly scan of AI news, plus things saved by hand.","path":"→ mathias/signals",
|
||||||
|
"trans_label":"Does it matter to us?","trans":"A raw signal only advances if it connects to something we actually care about. Most captured signals stop here; the few that touch the mission get pulled up against a goal.","nodes":[
|
||||||
|
{"t":"Applied AI Radar","plain":"An automated scan reads AI news every day (and deeper every week) and keeps only claims backed by a real paper, benchmark, code, or named lab.","d":"Daily Tier-1 + weekly Tier-2 deep pass. Verified-primary bar (paper/benchmark/code/named-lab).","tags":["cron · daily/weekly","→ signals #1–26+"]},
|
||||||
|
{"t":"Manual capture","plain":"Anything interesting spotted by hand gets saved into the same inbox.","d":"claude.ai strategic drop · brain capture tool.","tags":["ad-hoc"]},
|
||||||
|
{"t":"Aspirational surfaces","plain":"Planned but not built yet: sending ideas in by Telegram, voice, or a URL.","pill":"var(--dim)","d":"Telegram / voice / URL → inbox. NOT built.","tags":["gap"]}
|
||||||
|
]},
|
||||||
|
{"no":"STAGE 01","cls":"telos","title":"TELOS","plain_title":"Why we're here","plain":"The mission, goals, and problems we're actually trying to solve live here — every piece of work downstream has to trace back to one of these goals.","path":"wiki/telos/",
|
||||||
|
"trans_label":"Worth a session?","trans":"A goal or problem on the board becomes the seed for a design session when it's decided worth working on now. The goal is the input the session must trace back to.","nodes":[
|
||||||
|
{"t":"Intention substrate","plain":"The master list of mission, goals, problems, and current status — the yardstick everything downstream is measured against.","pill":"var(--violet)","d":"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.","tags":["brain_query wing=telos"]}
|
||||||
|
]},
|
||||||
|
{"no":"STAGE 02","title":"Strategic session","plain_title":"Think it through","plain":"A human and AI models work out what to do and why, debating hard calls and writing down the decision and what \"done\" will mean.","path":"claude.ai frontier + brain MCP",
|
||||||
|
"trans_label":"Decision reached","trans":"It advances only when the thinking converges on a decision and is written down as a concrete, testable specification — not while the answer is still open.","nodes":[
|
||||||
|
{"t":"Design · ADRs · specs","plain":"A human and a top-tier AI model figure out the approach and write down the decision plus what a finished result must prove.","d":"Human + frontier model. ISC acceptance criteria written here.","tags":["Define / converge"]},
|
||||||
|
{"t":"🏛️ LLM Council","plain":"For hard calls, several AI models answer independently, anonymously critique each other, and a \"chair\" model synthesises one verdict — reducing any single model's bias.","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]},
|
||||||
|
{"t":"Autoresearch Council","plain":"A parallel version of the same review that vets research findings before they're allowed through.","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]}
|
||||||
|
]},
|
||||||
|
{"no":"STAGE 03","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract",
|
||||||
|
"trans_label":"Order written, sealed, agent-ready","trans":"Advances to the gate only when the spec is a complete contract: a pass/fail test, a risk tier, a regulatory note, no open human dependencies, one embedded Oath, and a valid cryptographic signature. A malformed or unsigned order fails closed and never reaches the gate.","nodes":[
|
||||||
|
{"t":"Contract enforced","plain":"The work order must have a clear pass/fail test, a risk rating, a regulatory-risk note, and no unfinished human dependencies before it counts as agent-ready.","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]},
|
||||||
|
{"t":"Admission controller","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]},
|
||||||
|
{"t":"⚖️ var-go Oath","plain":"A machine-checkable \"definition of done\" is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec.","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]}
|
||||||
|
]},
|
||||||
|
{"no":"STAGE 04","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint",
|
||||||
|
"trans_label":"A human said go","trans":"The hard stop. Nothing crosses automatically — a person must review the plan and risk and explicitly release it, and the repo must be on the allow-list, before any agent starts. This is the single human checkpoint in the whole pipeline.","nodes":[
|
||||||
|
{"t":"Human triggers execution","plain":"A person confirms the plan and its risk level, then releases the work — nothing runs until they do.","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true},
|
||||||
|
{"t":"Session-Dispatch bridge","plain":"The approval flips a switch that hands the signed work order over to the agents to start execution.","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]}
|
||||||
|
]},
|
||||||
|
{"no":"STAGE 05","cls":"exec","title":"Execute · agentsquad","plain_title":"Agents do the work","plain":"AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail.","path":"koala · cmd/agentsquad-serve",
|
||||||
|
"trans_label":"Agents produced a change","trans":"Advances when the agents finish and open a proposed change (a PR) with its audit log attached. Until there's a concrete change to test, nothing moves.","nodes":[
|
||||||
|
{"t":"Task API","plain":"A request kicks off a job and hands back an id you can poll for progress.","pill":"var(--coral)","d":"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).","tags":["single agentsquad.yaml"]},
|
||||||
|
{"t":"Executor + reviewer loop","plain":"One agent does the work; a second, independent agent on a different model reviews it — so nothing marks its own homework.","cls":"win","pill":"var(--coral)","d":"ADK Go + LiteLLM. Frontier models (local qwen spirals). Reviewer on distinct tier — echo-chamber prevention.","risk":true},
|
||||||
|
{"t":"dma-cli · routing + scope","plain":"A router sends each agent to the right AI backend and enforces what it is and isn't allowed to touch, with a confirmation gate as a guardrail.","cls":"bridge","pill":"var(--blue)","d":"Harness-config arm: routes agents to the right LLM backend. Three-layer scope policy + confirmation gate = CAD guardrail.","tags":["backend routing","scope guardrail"]},
|
||||||
|
{"t":"assessor-loop ledger","plain":"Every step is recorded in a tamper-evident log so the whole run can be audited afterwards.","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]}
|
||||||
|
]},
|
||||||
|
{"no":"STAGE 06","title":"PR → CI","plain_title":"Automatic quality checks","plain":"The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue.","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs",
|
||||||
|
"trans_label":"All checks green","trans":"Advances only if every automated check passes — tests, linters, security scan, and the Oath check proving it meets the original work order. Any red gate stops it here; a passing reviewer is not enough to override a failed Oath.","nodes":[]},
|
||||||
|
{"no":"STAGE 07","cls":"cd","title":"CD → pod","plain_title":"Ship it","plain":"Once everything is green, the change is deployed automatically to the live server — with the rule that merging code alone doesn't ship it; the release has to be pointed at the new version.","path":"Flux GitOps → k3s","generate":"deploy-state",
|
||||||
|
"trans_label":"It's live","trans":"Once the new version is actually running on the server, the deployed outcome becomes the input to scoring. Advancing means shipped and observable, not just merged.","nodes":[
|
||||||
|
{"t":"Deploy on green","plain":"When all checks pass, the release system rolls the new version onto the live server automatically — but only once the release is pointed at that version (merging code alone doesn't ship it).","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","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)",
|
||||||
|
"trans_label":"What did we learn?","trans":"The scored outcome flows back into the mission board so goals, problems, and priorities update — the loop that makes the pipeline a cycle rather than a line. Partly manual today; an explicit improvement target.","nodes":[
|
||||||
|
{"t":"Close the loop","plain":"The outcome is scored against the goal that started it and written back to the mission board, so future planning learns from what actually shipped.","pill":"var(--violet)","d":"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)","tags":["continuous"]}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package atlas_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuild_OverlaysCIStageNodesFromWorkflow(t *testing.T) {
|
||||||
|
atlasJSON := []byte(`{
|
||||||
|
"substrate": [{"n":"koala","k":"gpu"}],
|
||||||
|
"stages": [
|
||||||
|
{"no":"STAGE 06","title":"PR → CI","generate":"ci-jobs","nodes":[]},
|
||||||
|
{"no":"STAGE 01","title":"TELOS","nodes":[{"t":"Intention substrate"}]}
|
||||||
|
]}`)
|
||||||
|
workflow := []byte("jobs:\n check:\n a: 1\n build:\n b: 2\n deploy:\n c: 3\n")
|
||||||
|
|
||||||
|
a, err := atlas.Build(atlasJSON, workflow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CI stage nodes generated from the workflow's jobs, in order.
|
||||||
|
var titles []string
|
||||||
|
for _, n := range a.Stages[0].Nodes {
|
||||||
|
titles = append(titles, n.Title)
|
||||||
|
}
|
||||||
|
if want := []string{"check", "build", "deploy"}; !reflect.DeepEqual(titles, want) {
|
||||||
|
t.Fatalf("CI stage nodes = %v, want %v", titles, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-generated stage is untouched.
|
||||||
|
if a.Stages[1].Nodes[0].Title != "Intention substrate" {
|
||||||
|
t.Fatalf("authored stage was altered: %+v", a.Stages[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substrate preserved.
|
||||||
|
if len(a.Substrate) != 1 || a.Substrate[0].Name != "koala" {
|
||||||
|
t.Fatalf("substrate not preserved: %+v", a.Substrate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefault_HasPlainLayerAndTransitions(t *testing.T) {
|
||||||
|
a, err := atlas.Build(atlas.DataJSON, []byte("jobs:\n guard:\n a: 1\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build embedded atlas: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range a.Stages {
|
||||||
|
if s.PlainTitle == "" {
|
||||||
|
t.Fatalf("stage %s missing plain_title", s.No)
|
||||||
|
}
|
||||||
|
if s.TransLabel == "" {
|
||||||
|
t.Fatalf("stage %s missing trans_label", s.No)
|
||||||
|
}
|
||||||
|
if s.Generate == "ci-jobs" {
|
||||||
|
continue // nodes are generated live, no authored plain
|
||||||
|
}
|
||||||
|
for _, n := range s.Nodes {
|
||||||
|
if n.Plain == "" {
|
||||||
|
t.Fatalf("stage %s node %q missing plain", s.No, n.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild_ErrorsOnBadAtlasJSON(t *testing.T) {
|
||||||
|
if _, err := atlas.Build([]byte("{not json"), []byte("jobs:\n x:\n a: 1\n")); err == nil {
|
||||||
|
t.Fatal("expected error on bad atlas JSON, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package atlas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HostsFromNodes parses a Kubernetes `/api/v1/nodes` list response into
|
||||||
|
// substrate Host entries, so the atlas machines reflect the live cluster.
|
||||||
|
func HostsFromNodes(nodesJSON []byte) ([]Host, error) {
|
||||||
|
var list struct {
|
||||||
|
Items []struct {
|
||||||
|
Metadata struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
Status struct {
|
||||||
|
Capacity map[string]string `json:"capacity"`
|
||||||
|
NodeInfo struct {
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
KubeletVersion string `json:"kubeletVersion"`
|
||||||
|
} `json:"nodeInfo"`
|
||||||
|
} `json:"status"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(nodesJSON, &list); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse nodes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts := make([]Host, 0, len(list.Items))
|
||||||
|
for _, it := range list.Items {
|
||||||
|
var parts []string
|
||||||
|
if a := it.Status.NodeInfo.Architecture; a != "" {
|
||||||
|
parts = append(parts, a)
|
||||||
|
}
|
||||||
|
if c := it.Status.Capacity["cpu"]; c != "" {
|
||||||
|
parts = append(parts, c+" cpu")
|
||||||
|
}
|
||||||
|
if gi := memGi(it.Status.Capacity["memory"]); gi != "" {
|
||||||
|
parts = append(parts, gi+"Gi")
|
||||||
|
}
|
||||||
|
if g := it.Status.Capacity["nvidia.com/gpu"]; g != "" && g != "0" {
|
||||||
|
parts = append(parts, g+"× GPU")
|
||||||
|
}
|
||||||
|
if v := k3sVersion(it.Status.NodeInfo.KubeletVersion); v != "" {
|
||||||
|
parts = append(parts, v)
|
||||||
|
}
|
||||||
|
hosts = append(hosts, Host{Name: it.Metadata.Name, Spec: strings.Join(parts, " · ")})
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// "ns: a · b · c" line for the substrate, dropping system namespaces, sorting,
|
||||||
|
// and capping the count (with "+N more" when it overflows).
|
||||||
|
func NamespaceSummary(nsJSON []byte) (string, error) {
|
||||||
|
var list struct {
|
||||||
|
Items []struct {
|
||||||
|
Metadata struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(nsJSON, &list); err != nil {
|
||||||
|
return "", fmt.Errorf("parse namespaces: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
for _, it := range list.Items {
|
||||||
|
n := it.Metadata.Name
|
||||||
|
if strings.HasPrefix(n, "kube-") || n == "default" || n == "flux-system" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
const maxShown = 12
|
||||||
|
more := 0
|
||||||
|
if len(names) > maxShown {
|
||||||
|
more = len(names) - maxShown
|
||||||
|
names = names[:maxShown]
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return "ns: (none)", nil
|
||||||
|
}
|
||||||
|
s := "ns: " + strings.Join(names, " · ")
|
||||||
|
if more > 0 {
|
||||||
|
s += " · +" + strconv.Itoa(more) + " more"
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSubstrate overlays live node specs onto the authored substrate: an
|
||||||
|
// authored host is replaced by the live node of the same name (fresh specs),
|
||||||
|
// authored-only machines (non-cluster: iguana/flamingo/piguard) are kept, and
|
||||||
|
// live-only nodes (new to the cluster) are appended.
|
||||||
|
func MergeSubstrate(authored, live []Host) []Host {
|
||||||
|
liveByName := make(map[string]Host, len(live))
|
||||||
|
for _, h := range live {
|
||||||
|
liveByName[h.Name] = h
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(authored))
|
||||||
|
out := make([]Host, 0, len(authored)+len(live))
|
||||||
|
for _, a := range authored {
|
||||||
|
if l, ok := liveByName[a.Name]; ok {
|
||||||
|
out = append(out, l)
|
||||||
|
} else {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
seen[a.Name] = true
|
||||||
|
}
|
||||||
|
for _, l := range live {
|
||||||
|
if !seen[l.Name] {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// memGi converts a Kubernetes memory quantity in Ki (e.g. "67108864Ki") to a
|
||||||
|
// rounded Gi string. Returns "" if unparseable.
|
||||||
|
func memGi(ki string) string {
|
||||||
|
n, err := strconv.ParseFloat(strings.TrimSuffix(ki, "Ki"), 64)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(int(math.Round(n / 1048576)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// k3sVersion trims a kubeletVersion's build metadata, labelling k3s builds.
|
||||||
|
// "v1.31.4+k3s1" → "k3s v1.31.4"; "v1.31.4" → "v1.31.4".
|
||||||
|
func k3sVersion(kubelet string) string {
|
||||||
|
if kubelet == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.Index(kubelet, "+"); i >= 0 {
|
||||||
|
ver, suffix := kubelet[:i], kubelet[i+1:]
|
||||||
|
if strings.Contains(suffix, "k3s") {
|
||||||
|
return "k3s " + ver
|
||||||
|
}
|
||||||
|
return ver
|
||||||
|
}
|
||||||
|
return kubelet
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package atlas_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHostsFromNodes_DerivesSpecFromNodeStatus(t *testing.T) {
|
||||||
|
nodes := []byte(`{"items":[
|
||||||
|
{"metadata":{"name":"koala"},
|
||||||
|
"status":{"capacity":{"cpu":"16","memory":"67108864Ki","nvidia.com/gpu":"1"},
|
||||||
|
"nodeInfo":{"architecture":"amd64","kubeletVersion":"v1.31.4+k3s1"}}},
|
||||||
|
{"metadata":{"name":"worker2"},
|
||||||
|
"status":{"capacity":{"cpu":"8","memory":"33554432Ki"},
|
||||||
|
"nodeInfo":{"architecture":"arm64","kubeletVersion":"v1.30.0+k3s1"}}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
hosts, err := atlas.HostsFromNodes(nodes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HostsFromNodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []atlas.Host{
|
||||||
|
{Name: "koala", Spec: "amd64 · 16 cpu · 64Gi · 1× GPU · k3s v1.31.4"},
|
||||||
|
{Name: "worker2", Spec: "arm64 · 8 cpu · 32Gi · k3s v1.30.0"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(hosts, want) {
|
||||||
|
t.Fatalf("hosts = %+v\nwant %+v", hosts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeSubstrate_LiveOverridesAuthoredKeepsRestAppendsNew(t *testing.T) {
|
||||||
|
authored := []atlas.Host{
|
||||||
|
{Name: "koala", Spec: "RTX 5070 · authored"},
|
||||||
|
{Name: "iguana", Spec: "M2 Ultra · Ollama / mlx"},
|
||||||
|
{Name: "piguard", Spec: "NGINX reverse-proxy · ntfy"},
|
||||||
|
}
|
||||||
|
live := []atlas.Host{
|
||||||
|
{Name: "koala", Spec: "amd64 · 16 cpu · 60Gi · 1× GPU · k3s v1.34.5"},
|
||||||
|
{Name: "worker9", Spec: "arm64 · 4 cpu"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := atlas.MergeSubstrate(authored, live)
|
||||||
|
want := []atlas.Host{
|
||||||
|
{Name: "koala", Spec: "amd64 · 16 cpu · 60Gi · 1× GPU · k3s v1.34.5"}, // live wins
|
||||||
|
{Name: "iguana", Spec: "M2 Ultra · Ollama / mlx"}, // authored kept
|
||||||
|
{Name: "piguard", Spec: "NGINX reverse-proxy · ntfy"}, // authored kept
|
||||||
|
{Name: "worker9", Spec: "arm64 · 4 cpu"}, // live-only appended
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("merge = %+v\nwant %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeSubstrate_NoLiveReturnsAuthored(t *testing.T) {
|
||||||
|
authored := []atlas.Host{{Name: "koala", Spec: "authored"}}
|
||||||
|
got := atlas.MergeSubstrate(authored, nil)
|
||||||
|
if !reflect.DeepEqual(got, authored) {
|
||||||
|
t.Fatalf("merge with no live = %+v, want %+v", got, authored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceSummary_FiltersSystemSortsAndJoins(t *testing.T) {
|
||||||
|
ns := []byte(`{"items":[
|
||||||
|
{"metadata":{"name":"gitea"}},
|
||||||
|
{"metadata":{"name":"kube-system"}},
|
||||||
|
{"metadata":{"name":"ai-stack"}},
|
||||||
|
{"metadata":{"name":"default"}},
|
||||||
|
{"metadata":{"name":"flux-system"}},
|
||||||
|
{"metadata":{"name":"brain"}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
got, err := atlas.NamespaceSummary(ns)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NamespaceSummary: %v", err)
|
||||||
|
}
|
||||||
|
if want := "ns: ai-stack · brain · gitea"; got != want {
|
||||||
|
t.Fatalf("summary = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceSummary_CapsWithMore(t *testing.T) {
|
||||||
|
var items []string
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
items = append(items, `{"metadata":{"name":"app`+string(rune('a'+i))+`"}}`)
|
||||||
|
}
|
||||||
|
ns := []byte(`{"items":[` + strings.Join(items, ",") + `]}`)
|
||||||
|
|
||||||
|
got, err := atlas.NamespaceSummary(ns)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NamespaceSummary: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "· +3 more") {
|
||||||
|
t.Fatalf("expected cap suffix, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if _, err := atlas.HostsFromNodes([]byte("{not json")); err == nil {
|
||||||
|
t.Fatal("expected error on bad JSON, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package atlas
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
// DataJSON is the authored atlas content (substrate + stage narrative).
|
||||||
|
// Sourced/generated facts are overlaid onto it at Build time.
|
||||||
|
//
|
||||||
|
//go:embed atlas.json
|
||||||
|
var DataJSON []byte
|
||||||
|
|
||||||
|
// Default builds the atlas from the embedded authored data, overlaying the
|
||||||
|
// given CI workflow (for the stage marked generate:ci-jobs).
|
||||||
|
func Default(workflow []byte) (Atlas, error) {
|
||||||
|
return Build(DataJSON, workflow)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package atlas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Host is a substrate machine/entry (koala, iguana, …).
|
||||||
|
type Host struct {
|
||||||
|
Name string `json:"n"`
|
||||||
|
Spec string `json:"k"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node is a card within a stage. Plain is the jargon-free default text; Desc is
|
||||||
|
// the technical detail shown on demand.
|
||||||
|
type Node struct {
|
||||||
|
Title string `json:"t"`
|
||||||
|
Plain string `json:"plain,omitempty"`
|
||||||
|
Desc string `json:"d,omitempty"`
|
||||||
|
Pill string `json:"pill,omitempty"`
|
||||||
|
Cls string `json:"cls,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Risk bool `json:"risk,omitempty"`
|
||||||
|
Gate bool `json:"gate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage is one column of the pipeline. PlainTitle/Plain are the plain-language
|
||||||
|
// default layer; Title/Path/Nodes[].Desc are the technical layer. TransLabel/
|
||||||
|
// Trans annotate the outgoing transition (the arrow to the next stage): what
|
||||||
|
// moves work forward and what must be true to advance. When Generate is set,
|
||||||
|
// Nodes are derived from a live source at Build time.
|
||||||
|
type Stage struct {
|
||||||
|
No string `json:"no"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
PlainTitle string `json:"plain_title,omitempty"`
|
||||||
|
Plain string `json:"plain,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Cls string `json:"cls,omitempty"`
|
||||||
|
Generate string `json:"generate,omitempty"`
|
||||||
|
TransLabel string `json:"trans_label,omitempty"`
|
||||||
|
Trans string `json:"trans,omitempty"`
|
||||||
|
Nodes []Node `json:"nodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build unmarshals the authored atlas JSON and overlays generated facts from
|
||||||
|
// real sources, so sourced parts can't drift. Currently: any stage marked
|
||||||
|
// `"generate":"ci-jobs"` gets its Nodes replaced by the workflow's job list.
|
||||||
|
func Build(atlasJSON, workflow []byte) (Atlas, error) {
|
||||||
|
var a Atlas
|
||||||
|
if err := json.Unmarshal(atlasJSON, &a); err != nil {
|
||||||
|
return Atlas{}, fmt.Errorf("parse atlas data: %w", err)
|
||||||
|
}
|
||||||
|
for i := range a.Stages {
|
||||||
|
if a.Stages[i].Generate != "ci-jobs" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jobs, err := JobsFromWorkflow(workflow)
|
||||||
|
if err != nil {
|
||||||
|
return Atlas{}, fmt.Errorf("stage %s: %w", a.Stages[i].No, err)
|
||||||
|
}
|
||||||
|
nodes := make([]Node, 0, len(jobs))
|
||||||
|
for _, j := range jobs {
|
||||||
|
nodes = append(nodes, Node{Title: j})
|
||||||
|
}
|
||||||
|
a.Stages[i].Nodes = nodes
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package atlas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Job is one job within a workflow run (a Gitea Actions "task").
|
||||||
|
type Job struct {
|
||||||
|
Name string
|
||||||
|
Status string
|
||||||
|
Conclusion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is the effective outcome: conclusion if set, else status.
|
||||||
|
func (j Job) State() string {
|
||||||
|
if j.Conclusion != "" {
|
||||||
|
return j.Conclusion
|
||||||
|
}
|
||||||
|
return j.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSummary is the newest workflow run and its per-job outcomes.
|
||||||
|
type RunSummary struct {
|
||||||
|
Number int
|
||||||
|
SHA string
|
||||||
|
Title string
|
||||||
|
Jobs []Job
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
pending := false
|
||||||
|
for _, j := range jobs {
|
||||||
|
switch j.State() {
|
||||||
|
case "failure", "cancelled", "error":
|
||||||
|
return "failure"
|
||||||
|
case "success", "skipped": // completed OK — skipped (e.g. deploy on a tag push) doesn't block
|
||||||
|
default:
|
||||||
|
pending = true // running / in_progress / waiting / queued / unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending {
|
||||||
|
return "running"
|
||||||
|
}
|
||||||
|
return "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries,
|
||||||
|
// newest first) and returns the newest run with its jobs in pipeline order.
|
||||||
|
func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
|
||||||
|
var resp struct {
|
||||||
|
Tasks []struct {
|
||||||
|
RunNumber int `json:"run_number"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Conclusion string `json:"conclusion"`
|
||||||
|
SHA string `json:"head_sha"`
|
||||||
|
Title string `json:"display_title"`
|
||||||
|
} `json:"workflow_runs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
|
||||||
|
return RunSummary{}, fmt.Errorf("parse tasks: %w", err)
|
||||||
|
}
|
||||||
|
if len(resp.Tasks) == 0 {
|
||||||
|
return RunSummary{}, fmt.Errorf("no workflow tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
latest := resp.Tasks[0]
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Gitea lists newest (last-finished) first; reverse to pipeline order.
|
||||||
|
for i, j := 0, len(s.Jobs)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
s.Jobs[i], s.Jobs[j] = s.Jobs[j], s.Jobs[i]
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunNodes renders a run as stage nodes: a summary node followed by one node
|
||||||
|
// per job, each coloured by outcome.
|
||||||
|
func RunNodes(s RunSummary) []Node {
|
||||||
|
sha := s.SHA
|
||||||
|
if len(sha) > 7 {
|
||||||
|
sha = sha[:7]
|
||||||
|
}
|
||||||
|
nodes := []Node{{
|
||||||
|
Title: fmt.Sprintf("▶ run #%d · %s", s.Number, s.State()),
|
||||||
|
Desc: s.Title,
|
||||||
|
Pill: statePill(s.State()),
|
||||||
|
Tags: []string{"live · Gitea Actions", sha},
|
||||||
|
}}
|
||||||
|
for _, j := range s.Jobs {
|
||||||
|
nodes = append(nodes, Node{Title: j.Name, Pill: statePill(j.State())})
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func statePill(state string) string {
|
||||||
|
switch state {
|
||||||
|
case "success":
|
||||||
|
return "var(--green)"
|
||||||
|
case "failure", "cancelled", "error":
|
||||||
|
return "var(--coral)"
|
||||||
|
}
|
||||||
|
return "var(--amber)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package atlas_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLatestRunJobs_GroupsNewestRunReversedToPipelineOrder(t *testing.T) {
|
||||||
|
// Gitea returns tasks newest-first (deploy finished last → appears first).
|
||||||
|
tasks := []byte(`{"workflow_runs":[
|
||||||
|
{"run_number":28,"name":"Deploy via GitOps","status":"success","head_sha":"633ba153f26abc","display_title":"feat: x"},
|
||||||
|
{"run_number":28,"name":"Build & Import","status":"success"},
|
||||||
|
{"run_number":28,"name":"Lint / Test / Vet","status":"success"},
|
||||||
|
{"run_number":27,"name":"Deploy via GitOps","status":"failure"}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
s, err := atlas.LatestRunJobs(tasks)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestRunJobs: %v", err)
|
||||||
|
}
|
||||||
|
if s.Number != 28 || s.SHA != "633ba153f26abc" || s.Title != "feat: x" {
|
||||||
|
t.Fatalf("summary = %+v", s)
|
||||||
|
}
|
||||||
|
// only run 28's jobs, reversed to pipeline order (Lint → Build → Deploy)
|
||||||
|
got := []string{}
|
||||||
|
for _, j := range s.Jobs {
|
||||||
|
got = append(got, j.Name)
|
||||||
|
}
|
||||||
|
want := []string{"Lint / Test / Vet", "Build & Import", "Deploy via GitOps"}
|
||||||
|
if len(got) != 3 || got[0] != want[0] || got[2] != want[2] {
|
||||||
|
t.Fatalf("jobs = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if s.State() != "success" {
|
||||||
|
t.Fatalf("state = %q, want success", s.State())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSummary_StateFailsIfAnyJobFailed(t *testing.T) {
|
||||||
|
s := atlas.RunSummary{Jobs: []atlas.Job{
|
||||||
|
{Status: "success"}, {Status: "completed", Conclusion: "failure"},
|
||||||
|
}}
|
||||||
|
if s.State() != "failure" {
|
||||||
|
t.Fatalf("state = %q, want failure", s.State())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNodes_SummaryThenPerJobColoured(t *testing.T) {
|
||||||
|
s := atlas.RunSummary{Number: 28, SHA: "633ba153f26", Title: "feat: x", Jobs: []atlas.Job{
|
||||||
|
{Name: "Lint / Test / Vet", Status: "success"},
|
||||||
|
{Name: "Deploy via GitOps", Status: "failure"},
|
||||||
|
}}
|
||||||
|
nodes := atlas.RunNodes(s)
|
||||||
|
if len(nodes) != 3 {
|
||||||
|
t.Fatalf("want 3 nodes (summary + 2 jobs), got %d", len(nodes))
|
||||||
|
}
|
||||||
|
if nodes[0].Title != "▶ run #28 · failure" || nodes[0].Pill != "var(--coral)" {
|
||||||
|
t.Fatalf("summary node = %+v", nodes[0])
|
||||||
|
}
|
||||||
|
if nodes[1].Title != "Lint / Test / Vet" || nodes[1].Pill != "var(--green)" {
|
||||||
|
t.Fatalf("job node 1 = %+v", nodes[1])
|
||||||
|
}
|
||||||
|
if nodes[2].Pill != "var(--coral)" {
|
||||||
|
t.Fatalf("failed job pill = %q", nodes[2].Pill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if _, err := atlas.LatestRunJobs([]byte(`{"workflow_runs":[]}`)); err == nil {
|
||||||
|
t.Fatal("expected error on empty, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Package atlas builds the CAD Atlas data model, deriving parts from real
|
||||||
|
// sources (the repo's own CI workflow, infra manifests, the live cluster)
|
||||||
|
// so the visualization can't drift from reality.
|
||||||
|
package atlas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobsFromWorkflow extracts the job names, in document order, from a Gitea
|
||||||
|
// Actions / GitHub Actions workflow YAML. Used to generate the CI/CD stage of
|
||||||
|
// the atlas from the pipeline that actually runs, rather than hand-authoring it.
|
||||||
|
func JobsFromWorkflow(workflow []byte) ([]string, error) {
|
||||||
|
var doc yaml.Node
|
||||||
|
if err := yaml.Unmarshal(workflow, &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse workflow: %w", err)
|
||||||
|
}
|
||||||
|
if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
|
||||||
|
return nil, fmt.Errorf("workflow: expected a top-level mapping")
|
||||||
|
}
|
||||||
|
jobs := mappingValue(doc.Content[0], "jobs")
|
||||||
|
if jobs == nil {
|
||||||
|
return nil, fmt.Errorf("workflow: no jobs block")
|
||||||
|
}
|
||||||
|
if jobs.Kind != yaml.MappingNode {
|
||||||
|
return nil, fmt.Errorf("workflow: jobs is not a mapping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mapping node stores keys and values as alternating Content entries;
|
||||||
|
// keys are the even indices, in document order.
|
||||||
|
names := make([]string, 0, len(jobs.Content)/2)
|
||||||
|
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||||
|
names = append(names, jobs.Content[i].Value)
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mappingValue returns the value node for key in a YAML mapping node, or nil.
|
||||||
|
func mappingValue(m *yaml.Node, key string) *yaml.Node {
|
||||||
|
for i := 0; i+1 < len(m.Content); i += 2 {
|
||||||
|
if m.Content[i].Value == key {
|
||||||
|
return m.Content[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package atlas_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJobsFromWorkflow_ReturnsJobNamesInDocumentOrder(t *testing.T) {
|
||||||
|
workflow := []byte(`
|
||||||
|
name: CD
|
||||||
|
"on":
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
jobs:
|
||||||
|
guard:
|
||||||
|
runs-on: self-hosted
|
||||||
|
check:
|
||||||
|
needs: guard
|
||||||
|
runs-on: self-hosted
|
||||||
|
build:
|
||||||
|
needs: [guard, check]
|
||||||
|
runs-on: self-hosted
|
||||||
|
deploy:
|
||||||
|
needs: [guard, build]
|
||||||
|
runs-on: self-hosted
|
||||||
|
`)
|
||||||
|
|
||||||
|
jobs, err := atlas.JobsFromWorkflow(workflow)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("JobsFromWorkflow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"guard", "check", "build", "deploy"}
|
||||||
|
if !reflect.DeepEqual(jobs, want) {
|
||||||
|
t.Fatalf("jobs = %v, want %v", jobs, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsFromWorkflow_ErrorsOnMalformedYAML(t *testing.T) {
|
||||||
|
if _, err := atlas.JobsFromWorkflow([]byte("jobs: [this is: not valid")); err == nil {
|
||||||
|
t.Fatal("expected error on malformed YAML, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Package cluster reads live k3s state from inside a pod via the Kubernetes
|
||||||
|
// API, using the mounted ServiceAccount credentials. No client-go: the queries
|
||||||
|
// are read-only and few, so stdlib net/http keeps the dependency surface small.
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // well-known in-cluster path, not a secret literal
|
||||||
|
caPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nodes returns the raw /api/v1/nodes JSON from the in-cluster API server.
|
||||||
|
func Nodes() ([]byte, error) { return get("/api/v1/nodes") }
|
||||||
|
|
||||||
|
// Namespaces returns the raw /api/v1/namespaces JSON from the in-cluster API server.
|
||||||
|
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) {
|
||||||
|
host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
|
||||||
|
if host == "" || port == "" {
|
||||||
|
return nil, fmt.Errorf("not in-cluster: KUBERNETES_SERVICE_HOST unset")
|
||||||
|
}
|
||||||
|
token, err := os.ReadFile(tokenPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read sa token: %w", err)
|
||||||
|
}
|
||||||
|
ca, err := os.ReadFile(caPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ca: %w", err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(ca) {
|
||||||
|
return nil, fmt.Errorf("invalid cluster CA cert")
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "https://"+host+":"+port+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+string(token))
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("k8s API GET %s: %s", path, resp.Status)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Package gitea reads the repo's own Gitea Actions run history from the
|
||||||
|
// in-cluster Gitea service (public read — no token), so the atlas can show the
|
||||||
|
// pipeline's live executions.
|
||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// base is the in-cluster Gitea service by default; override with GITEA_BASE.
|
||||||
|
func base() string {
|
||||||
|
if b := os.Getenv("GITEA_BASE"); b != "" {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return "http://gitea-http.gitea.svc.cluster.local:3000"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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=50"
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("gitea runs: %s", resp.Status)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Package version holds the build version, injected at build time via
|
||||||
|
// -ldflags "-X .../internal/version.Value=$(git describe --tags --always)".
|
||||||
|
package version
|
||||||
|
|
||||||
|
// Value is the build version. Defaults to "dev" for local/un-injected builds.
|
||||||
|
var Value = "dev"
|
||||||
+114
-8
@@ -1,20 +1,28 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cadatlas "git.d-ma.be/mathias/cad-atlas"
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/cluster"
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/gitea"
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// atlasHTML is the Phase-A static hero visualization. Phase C replaces this
|
// atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load
|
||||||
// self-contained file with a Templ view hydrated from live CAD trace data
|
// and renders from that data (no inline arrays), so the content is sourced.
|
||||||
// (assessor-loop ledger, session_log, Gitea run API, Flux events).
|
|
||||||
//
|
//
|
||||||
//go:embed static/cad-atlas.html
|
//go:embed static/cad-atlas.html
|
||||||
var atlasHTML []byte
|
var atlasHTML []byte
|
||||||
|
|
||||||
// NewHandler serves the CAD Atlas. Root ("/") returns the static atlas;
|
// NewHandler serves the CAD Atlas: the shell at "/", and the sourced data at
|
||||||
// /api/hello is a leftover template probe kept until Phase C wires real endpoints.
|
// "/api/atlas.json" (authored data + CI stage generated from the real cd.yml +
|
||||||
|
// substrate overlaid from the live cluster when reachable).
|
||||||
func NewHandler() http.Handler {
|
func NewHandler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -25,8 +33,106 @@ func NewHandler() http.Handler {
|
|||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = w.Write(atlasHTML)
|
_, _ = w.Write(atlasHTML)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/atlas.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = Hello("world").Render(context.Background(), w)
|
a, err := atlas.Default(cadatlas.CDWorkflow)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "atlas build failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.Version = version.Value
|
||||||
|
ld := liveOverlay()
|
||||||
|
if len(ld.hosts) > 0 {
|
||||||
|
a.Substrate = atlas.MergeSubstrate(a.Substrate, ld.hosts)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
if a.Stages[i].Generate == "ci-jobs" {
|
||||||
|
a.Stages[i].Nodes = nodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
_ = json.NewEncoder(w).Encode(a)
|
||||||
})
|
})
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// liveOverlay holds cluster-sourced substrate facts (node specs + namespace line).
|
||||||
|
type liveOverlayData struct {
|
||||||
|
hosts []atlas.Host
|
||||||
|
ns string
|
||||||
|
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
|
||||||
|
// substrate/ns (zero values) whenever the cluster is unreachable.
|
||||||
|
var (
|
||||||
|
liveMu sync.Mutex
|
||||||
|
liveCache liveOverlayData
|
||||||
|
liveAt time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
const liveTTL = 30 * time.Second
|
||||||
|
|
||||||
|
func liveOverlay() liveOverlayData {
|
||||||
|
liveMu.Lock()
|
||||||
|
defer liveMu.Unlock()
|
||||||
|
if !liveAt.IsZero() && time.Since(liveAt) < liveTTL {
|
||||||
|
return liveCache
|
||||||
|
}
|
||||||
|
liveAt = time.Now()
|
||||||
|
|
||||||
|
var d liveOverlayData
|
||||||
|
if raw, err := cluster.Nodes(); err == nil {
|
||||||
|
if hosts, err := atlas.HostsFromNodes(raw); err == nil {
|
||||||
|
d.hosts = hosts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, err := cluster.Namespaces(); err == nil {
|
||||||
|
if s, err := atlas.NamespaceSummary(raw); err == nil {
|
||||||
|
d.ns = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, err := gitea.Runs(); err == nil {
|
||||||
|
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 {
|
||||||
|
d.deploy = &dep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, err := cluster.FluxKustomization(); err == nil {
|
||||||
|
if f, err := atlas.FluxStatus(raw); err == nil {
|
||||||
|
d.flux = &f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
liveCache = d
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRootServesAtlas(t *testing.T) {
|
func TestRootServesAtlas(t *testing.T) {
|
||||||
@@ -27,6 +30,43 @@ func TestRootServesAtlas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAtlasJSON_ServesAtlasWithGeneratedCIStage(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(NewHandler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/api/atlas.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /api/atlas.json: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var a atlas.Atlas
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&a); err != nil {
|
||||||
|
t.Fatalf("decode atlas: %v", err)
|
||||||
|
}
|
||||||
|
if len(a.Stages) == 0 {
|
||||||
|
t.Fatal("atlas has no stages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The generate:ci-jobs stage must be populated from the real cd.yml jobs.
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, s := range a.Stages {
|
||||||
|
if s.No == "STAGE 06" {
|
||||||
|
for _, n := range s.Nodes {
|
||||||
|
got[n.Title] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"guard", "check", "build", "deploy"} {
|
||||||
|
if !got[want] {
|
||||||
|
t.Fatalf("CI stage missing generated job %q (got %v)", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnknownPath404(t *testing.T) {
|
func TestUnknownPath404(t *testing.T) {
|
||||||
srv := httptest.NewServer(NewHandler())
|
srv := httptest.NewServer(NewHandler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|||||||
@@ -52,6 +52,31 @@
|
|||||||
.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}
|
||||||
|
|
||||||
|
/* progressive disclosure */
|
||||||
|
.tech-sub{color:var(--dim);font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;margin:1px 0 5px}
|
||||||
|
.plain-what{color:var(--ink);font-size:12.5px;line-height:1.45;margin-bottom:2px;opacity:.92}
|
||||||
|
.translabel{fill:var(--mono);font-size:10px;font-family:ui-monospace,SFMono-Regular,monospace}
|
||||||
|
.translabel-gate{fill:var(--gold);font-weight:700}
|
||||||
|
.stagehead{min-height:64px}
|
||||||
|
body.plain .stagehead{min-height:188px}
|
||||||
|
.stage .node:first-of-type{margin-top:24px}
|
||||||
|
/* Plain view hides the jargon-dense infra ribbon + technical footer */
|
||||||
|
body.plain .substrate{display:none}
|
||||||
|
body.plain footer{display:none}
|
||||||
|
.legend{display:flex;gap:16px;flex-wrap:wrap;align-items:center;
|
||||||
|
padding:7px 22px;border-bottom:1px solid var(--line);background:var(--bg);font-size:11px;color:var(--dim)}
|
||||||
|
.legend .lbl{letter-spacing:1.5px;margin-right:2px}
|
||||||
|
.legend .lg{display:inline-flex;align-items:center;gap:6px}
|
||||||
|
.legend .lg i{width:11px;height:11px;border-radius:3px;display:inline-block;border:1px solid rgba(0,0,0,.35)}
|
||||||
|
.legend .dash{width:18px;border-top:2px dashed var(--violet);display:inline-block}
|
||||||
|
|
||||||
.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}
|
||||||
@@ -114,14 +139,24 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
|
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
|
||||||
<span class="sub mono">one human gate · everything up- and downstream is agents · <em>v0.3 static snapshot (→ live in Phase C)</em></span>
|
<span class="sub mono">one human gate · everything up- and downstream is agents · <em id="ver">dev</em></span>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
<button id="mode" class="on">View · <span id="modeState">Plain</span></button>
|
||||||
<button id="replay"><span class="dot"></span> Replay</button>
|
<button id="replay"><span class="dot"></span> Replay</button>
|
||||||
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
|
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
|
||||||
</div>
|
</div>
|
||||||
</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="legend mono">
|
||||||
|
<span class="lbl">KEY</span>
|
||||||
|
<span class="lg"><i style="background:var(--green)"></i>passed / ready</span>
|
||||||
|
<span class="lg"><i style="background:var(--amber)"></i>running / waiting</span>
|
||||||
|
<span class="lg"><i style="background:var(--coral)"></i>failed / blocked</span>
|
||||||
|
<span class="lg"><i style="background:var(--gold)"></i>governance gate — must pass to advance</span>
|
||||||
|
<span class="lg"><span class="dash"></span> feedback loop · partly manual</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
<div class="track" id="track">
|
<div class="track" id="track">
|
||||||
@@ -137,6 +172,7 @@
|
|||||||
stroke-dasharray="5 5" opacity=".7"></path>
|
stroke-dasharray="5 5" opacity=".7"></path>
|
||||||
<text id="loopLbl" fill="#9b8cff" font-size="11"
|
<text id="loopLbl" fill="#9b8cff" font-size="11"
|
||||||
font-family="ui-monospace,monospace" opacity=".85"></text>
|
font-family="ui-monospace,monospace" opacity=".85"></text>
|
||||||
|
<g id="translabels"></g>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="pulse" id="pulse"></div>
|
<div class="pulse" id="pulse"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,93 +181,79 @@
|
|||||||
<footer class="mono">
|
<footer class="mono">
|
||||||
CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship.
|
CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship.
|
||||||
Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal).
|
Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal).
|
||||||
Data: static inventory from <code>brain</code> (2026-07-19). Phase C swaps these arrays for live reads of
|
Data served from <code>/api/atlas.json</code> (authored <code>atlas.json</code> + CI stage from the live <code>cd.yml</code> + substrate from the live cluster nodes).
|
||||||
<code>assessor-loop</code> ledger · <code>session_log</code> · Gitea run API · Flux events.
|
Phase C: <code>Gitea run API</code> live (latest run on the CI stage). Pending: <code>assessor-loop</code> ledger · <code>session_log</code> · per-job status · Flux events.
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const SUBSTRATE=[
|
// Single source of truth: /api/atlas.json (Go app — authored atlas.json + CI
|
||||||
{n:"koala", k:"RTX 5070 · k3s control-plane · Gitea · LiteLLM :30401 · llama-swap :31234 · searxng"},
|
// stage from the live cd.yml + substrate from the live cluster). These start
|
||||||
{n:"iguana", k:"M2 Ultra · Ollama / mlx"},
|
// empty and are filled by init()'s fetch; on failure the page shows an error
|
||||||
{n:"flamingo",k:"daily driver · ~/dev"},
|
// banner rather than stale inline data.
|
||||||
{n:"piguard",k:"NGINX reverse-proxy · ntfy"},
|
let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[];
|
||||||
];
|
let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
|
||||||
const NS="Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council";
|
|
||||||
|
|
||||||
const STAGES=[
|
|
||||||
{no:"STAGE 00",cls:"",title:"Signals",path:"→ mathias/signals",nodes:[
|
|
||||||
{t:"Applied AI Radar",d:"Daily Tier-1 + weekly Tier-2 deep pass. Verified-primary bar (paper/benchmark/code/named-lab).",tags:["cron · daily/weekly","→ signals #1–26+"]},
|
|
||||||
{t:"Manual capture",d:"claude.ai strategic drop · brain capture tool.",tags:["ad-hoc"]},
|
|
||||||
{t:"Aspirational surfaces",pill:"var(--dim)",d:"Telegram / voice / URL → inbox. NOT built.",tags:["gap"]},
|
|
||||||
]},
|
|
||||||
{no:"STAGE 01",cls:"telos",title:"TELOS",path:"wiki/telos/",nodes:[
|
|
||||||
{t:"Intention substrate",pill:"var(--violet)",d:"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.",tags:["brain_query wing=telos"]},
|
|
||||||
]},
|
|
||||||
{no:"STAGE 02",cls:"",title:"Strategic session",path:"claude.ai frontier + brain MCP",nodes:[
|
|
||||||
{t:"Design · ADRs · specs",d:"Human + frontier model. ISC acceptance criteria written here.",tags:["Define / converge"]},
|
|
||||||
{t:"🏛️ LLM Council",cls:"council",pill:"var(--violet)",d:"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).",tags:["hard strategic Q","chat.d-ma.be"]},
|
|
||||||
{t:"Autoresearch Council",cls:"council",pill:"var(--violet)",d:"Sibling pipe — ratifies research before the gate.",tags:["proposed: → standalone svc"]},
|
|
||||||
]},
|
|
||||||
{no:"STAGE 03",cls:"",title:"Spec → Gitea issue",path:"agent-ready contract",nodes:[
|
|
||||||
{t:"Contract enforced",d:"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.",tags:["LOW / MED / HIGH"]},
|
|
||||||
{t:"Admission controller",d:"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.",tags:["chain of custody"]},
|
|
||||||
{t:"⚖️ var-go Oath",cls:"oath",pill:"var(--gold)",d:"Acceptance contract embedded in the issue as a ```var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.",tags:["swedsl · var-go","defined here → enforced @06"]},
|
|
||||||
]},
|
|
||||||
{no:"STAGE 04",cls:"gate",title:"Human dispatch gate",path:"the only checkpoint",nodes:[
|
|
||||||
{t:"Human triggers execution",cls:"gateway",pill:"var(--amber)",d:"Ratify proposed-plan + risk tier, then dispatch.",gate:true},
|
|
||||||
{t:"Session-Dispatch bridge",cls:"bridge",pill:"var(--blue)",d:"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.",tags:["workflow_dispatch"]},
|
|
||||||
]},
|
|
||||||
{no:"STAGE 05",cls:"exec",title:"Execute · agentsquad",path:"koala · cmd/agentsquad-serve",nodes:[
|
|
||||||
{t:"Task API",pill:"var(--coral)",d:"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).",tags:["single agentsquad.yaml"]},
|
|
||||||
{t:"Executor + reviewer loop",cls:"win",pill:"var(--coral)",d:"ADK Go + LiteLLM. Frontier models (local qwen spirals). Reviewer on distinct tier — echo-chamber prevention.",risk:true},
|
|
||||||
{t:"dma-cli · routing + scope",cls:"bridge",pill:"var(--blue)",d:"Harness-config arm: routes agents to the right LLM backend. Three-layer scope policy + confirmation gate = CAD guardrail.",tags:["backend routing","scope guardrail"]},
|
|
||||||
{t:"assessor-loop ledger",d:"Attestation ledger (audit trail) + brain session_log on completion.",tags:["audit package"]},
|
|
||||||
]},
|
|
||||||
{no:"STAGE 06",cls:"",title:"PR → CI",path:"Gitea Actions",nodes:[
|
|
||||||
{t:"PR + label",d:"Gitea PR · agent-done / agent-blocked label.",tags:[]},
|
|
||||||
{t:"Mechanical gate",d:"go test · vet · lint · govulncheck. ISC verified mechanically.",tags:["green = proceed"]},
|
|
||||||
{t:"⚖️ var-go/oath gate",cls:"oath",pill:"var(--gold)",d:"cmd/vargo-gate runs in CI → posts commit status context=var-go/oath. Authoritative FLOOR: failed Oath blocks regardless of reviewer approval (#55 anti-rubber-stamp).",tags:["branch-protection req","enforces @03 Oath"]},
|
|
||||||
]},
|
|
||||||
{no:"STAGE 07",cls:"cd",title:"CD → pod",path:"Flux GitOps → k3s",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"]},
|
|
||||||
]},
|
|
||||||
{no:"STAGE 08",cls:"telos",title:"Loop back",path:"→ TELOS (feedback bus)",nodes:[
|
|
||||||
{t:"Close the loop",pill:"var(--violet)",d:"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)",tags:["continuous"]},
|
|
||||||
]},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
const track=document.getElementById('track');
|
||||||
|
let stageEls=[];
|
||||||
|
function renderAtlas(){
|
||||||
const sub=document.getElementById('substrate');
|
const sub=document.getElementById('substrate');
|
||||||
|
sub.querySelectorAll('.host').forEach(el=>el.remove());
|
||||||
SUBSTRATE.forEach(h=>{const el=document.createElement('div');el.className='host';
|
SUBSTRATE.forEach(h=>{const el=document.createElement('div');el.className='host';
|
||||||
el.innerHTML=`<b>${h.n}</b><span class="k mono">${h.k}</span>`;sub.appendChild(el);});
|
el.innerHTML=`<b>${h.n}</b><span class="k mono">${h.k}</span>`;sub.appendChild(el);});
|
||||||
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
|
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
|
||||||
|
stageEls=[];
|
||||||
const track=document.getElementById('track');
|
track.querySelectorAll('.stage').forEach(el=>el.remove());
|
||||||
const stageEls=[];
|
const plain = MODE==='plain';
|
||||||
STAGES.forEach(s=>{
|
STAGES.forEach(s=>{
|
||||||
const st=document.createElement('div');st.className='stage '+s.cls;
|
const st=document.createElement('div');st.className='stage '+(s.cls||'');
|
||||||
let h=`<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
|
const head = plain
|
||||||
s.nodes.forEach(n=>{
|
? `<div class="no mono">${s.no}</div><h2>${s.plain_title||s.title}</h2>`+
|
||||||
|
`<div class="tech-sub">${s.title}</div>`+(s.plain?`<div class="plain-what">${s.plain}</div>`:'')
|
||||||
|
: `<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
|
||||||
|
let h = `<div class="stagehead">${head}</div>`;
|
||||||
|
(s.nodes||[]).forEach(n=>{
|
||||||
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
|
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
|
||||||
let inner=`<div class="t">${pill}${n.t}</div><div class="d">${n.d}</div>`;
|
const body = plain ? (n.plain||n.d||'') : (n.d||'');
|
||||||
|
let inner=`<div class="t">${pill}${n.t}</div>`+(body?`<div class="d">${body}</div>`:'');
|
||||||
|
if(!plain){
|
||||||
if(n.tags&&n.tags.length)inner+=n.tags.map(t=>`<span class="tag mono">${t}</span>`).join('');
|
if(n.tags&&n.tags.length)inner+=n.tags.map(t=>`<span class="tag mono">${t}</span>`).join('');
|
||||||
if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`;
|
if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`;
|
||||||
|
}
|
||||||
if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`;
|
if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`;
|
||||||
h+=`<div class="node ${n.cls||''}">${inner}</div>`;
|
h+=`<div class="node ${n.cls||''}">${inner}</div>`;
|
||||||
});
|
});
|
||||||
st.innerHTML=h;track.appendChild(st);stageEls.push(st);
|
st.innerHTML=h;track.appendChild(st);stageEls.push(st);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
pulse=document.getElementById('pulse');
|
pulse=document.getElementById('pulse');
|
||||||
const RAILY=70;
|
let RAILY=70;
|
||||||
let cs=[],loopY=0,spineLen=0,loopLen=0,slow=false,raf=null,t0=null,mobile=false;
|
let cs=[],loopY=0,spineLen=0,loopLen=0,slow=false,raf=null,t0=null,mobile=false;
|
||||||
|
|
||||||
function build(){
|
function build(){
|
||||||
mobile=window.matchMedia('(max-width:820px)').matches;
|
mobile=window.matchMedia('(max-width:820px)').matches;
|
||||||
if(mobile)return;
|
if(mobile)return;
|
||||||
cs=stageEls.map(s=>s.offsetLeft+s.offsetWidth/2);
|
cs=stageEls.map(s=>s.offsetLeft+s.offsetWidth/2);
|
||||||
|
// spine sits in the band between the (uniform) stage headers and the first node
|
||||||
|
const firstTops=stageEls.map(s=>{const n=s.querySelector('.node');return n?s.offsetTop+n.offsetTop:120;});
|
||||||
|
RAILY=Math.max(66, Math.min(...firstTops)-16);
|
||||||
const maxBottom=Math.max(...stageEls.map(s=>s.offsetTop+s.offsetHeight));
|
const maxBottom=Math.max(...stageEls.map(s=>s.offsetTop+s.offsetHeight));
|
||||||
loopY=maxBottom+40;
|
loopY=maxBottom+40;
|
||||||
spine.setAttribute('width',track.scrollWidth);
|
spine.setAttribute('width',track.scrollWidth);
|
||||||
@@ -241,7 +263,22 @@ function build(){
|
|||||||
const lastX=cs[cs.length-1], telosX=cs[1];
|
const lastX=cs[cs.length-1], telosX=cs[1];
|
||||||
loopPath.setAttribute('d',`M ${lastX} ${RAILY} L ${lastX} ${loopY} L ${telosX} ${loopY} L ${telosX} ${RAILY}`);
|
loopPath.setAttribute('d',`M ${lastX} ${RAILY} L ${lastX} ${loopY} L ${telosX} ${loopY} L ${telosX} ${RAILY}`);
|
||||||
loopLbl.setAttribute('x',(telosX+lastX)/2-90);loopLbl.setAttribute('y',loopY-8);
|
loopLbl.setAttribute('x',(telosX+lastX)/2-90);loopLbl.setAttribute('y',loopY-8);
|
||||||
loopLbl.textContent='feedback bus · outcome → goal';
|
loopLbl.textContent=(STAGES[8]&&STAGES[8].trans_label?STAGES[8].trans_label+' · ':'')+'feedback bus · partly manual today';
|
||||||
|
// transition labels on the spine — the "what must be true to advance" story.
|
||||||
|
// Always visible (default layer). Gate hops (04→05 human, 06→07 CI) in gold.
|
||||||
|
const tg=document.getElementById('translabels'); tg.innerHTML='';
|
||||||
|
for(let i=0;i<cs.length-1;i++){
|
||||||
|
const s=STAGES[i]; if(!s||!s.trans_label) continue;
|
||||||
|
const gate = (i===4||i===6);
|
||||||
|
const t=document.createElementNS('http://www.w3.org/2000/svg','text');
|
||||||
|
t.setAttribute('x',(cs[i]+cs[i+1])/2);t.setAttribute('y',RAILY-9);
|
||||||
|
t.setAttribute('text-anchor','middle');
|
||||||
|
t.setAttribute('class',gate?'translabel translabel-gate':'translabel');
|
||||||
|
t.textContent=(gate?'▸ ':'')+s.trans_label;
|
||||||
|
const ttl=document.createElementNS('http://www.w3.org/2000/svg','title');
|
||||||
|
ttl.textContent=s.trans||''; t.appendChild(ttl);
|
||||||
|
tg.appendChild(t);
|
||||||
|
}
|
||||||
spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength();
|
spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength();
|
||||||
}
|
}
|
||||||
function run(ts){
|
function run(ts){
|
||||||
@@ -264,9 +301,41 @@ function replay(){cancelAnimationFrame(raf);t0=null;build();if(!mobile)raf=reque
|
|||||||
document.getElementById('replay').onclick=replay;
|
document.getElementById('replay').onclick=replay;
|
||||||
document.getElementById('slowmo').onclick=e=>{slow=!slow;e.currentTarget.classList.toggle('on',slow);
|
document.getElementById('slowmo').onclick=e=>{slow=!slow;e.currentTarget.classList.toggle('on',slow);
|
||||||
document.getElementById('slowState').textContent=slow?'on':'off';replay();};
|
document.getElementById('slowState').textContent=slow?'on':'off';replay();};
|
||||||
|
function applyMode(){
|
||||||
|
document.getElementById('modeState').textContent = MODE==='plain'?'Plain':'Technical';
|
||||||
|
document.getElementById('mode').classList.toggle('on', MODE==='plain');
|
||||||
|
document.body.classList.toggle('plain', MODE==='plain');
|
||||||
|
}
|
||||||
|
document.getElementById('mode').onclick=()=>{
|
||||||
|
MODE = MODE==='plain' ? 'technical' : 'plain';
|
||||||
|
localStorage.setItem('atlas-mode',MODE);
|
||||||
|
applyMode(); renderAtlas(); replay();
|
||||||
|
};
|
||||||
window.addEventListener('resize',()=>{clearTimeout(window._r);window._r=setTimeout(replay,150);});
|
window.addEventListener('resize',()=>{clearTimeout(window._r);window._r=setTimeout(replay,150);});
|
||||||
window.addEventListener('load',replay);
|
async function init(){
|
||||||
build();
|
try{
|
||||||
|
const r=await fetch('/api/atlas.json');
|
||||||
|
if(!r.ok) throw new Error('atlas.json '+r.status);
|
||||||
|
const data=await r.json();
|
||||||
|
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);
|
||||||
|
document.getElementById('track').insertAdjacentHTML('beforeend',
|
||||||
|
'<div class="stage"><div class="no mono">ERROR</div>'+
|
||||||
|
'<h2 style="color:var(--coral)">Data unavailable</h2>'+
|
||||||
|
'<div class="path mono">/api/atlas.json failed to load</div></div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyMode();
|
||||||
|
renderAtlas();
|
||||||
|
renderTimeline();
|
||||||
|
replay();
|
||||||
|
}
|
||||||
|
window.addEventListener('load',init);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user