package web import ( "context" _ "embed" "encoding/json" "net/http" "os" "strings" "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" "git.d-ma.be/mathias/cad/adapters/gitearead" cadapp "git.d-ma.be/mathias/cad/app" caddomain "git.d-ma.be/mathias/cad/domain" ) // atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load // and renders from that data (no inline arrays), so the content is sourced. // //go:embed static/cad-atlas.html var atlasHTML []byte // NewHandler serves the CAD Atlas: the shell at "/", and the sourced data at // "/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 { mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(atlasHTML) }) mux.HandleFunc("/api/atlas.json", func(w http.ResponseWriter, r *http.Request) { 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.Has("ci-jobs") { a.Stages[i].Nodes = nodes } } a.CIDurationS, a.CDDurationS = atlas.StageSeconds(*ld.run) } atlas.PrependGenerated(&a, "gitea-issues", ld.issues) atlas.PrependGenerated(&a, "gitea-repos", ld.repos) // cad-atlas#11: overlay mathias/cad#14's cad-stuck findings as // per-stage badges. Read-only — ApplyStuckOverlay only adds Nodes // with a link out; it never writes anything back to Gitea. a.Stages = atlas.ApplyStuckOverlay(a.Stages, ld.stuck) 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.Has("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 } // 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 issues []atlas.Node repos []atlas.Node stuck []caddomain.StuckFinding } // stuckRepos is the estate cad-stuck queries — mirrors mathias/dispatch's // dispatch-repos.txt scope. Override with CAD_ATLAS_STUCK_REPOS (comma // separated "owner/name" entries) so this doesn't need a redeploy every time // the estate's repo list changes; cad-atlas has no filesystem access to the // canonical dispatch-repos.txt at runtime. var defaultStuckRepos = []string{ "mathias/dispatch-sandbox", "mathias/cobalt-dingo", "mathias/parallax", "mathias/cad-atlas", "mathias/brain-gardener", "mathias/hyperguild", "mathias/brain", } func stuckRepos() []string { if v := os.Getenv("CAD_ATLAS_STUCK_REPOS"); v != "" { var repos []string for _, r := range strings.Split(v, ",") { if r = strings.TrimSpace(r); r != "" { repos = append(repos, r) } } return repos } return defaultStuckRepos } // 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 := gitea.MyIssues(); err == nil { if nodes, err := atlas.IssueNodes(raw); err == nil { d.issues = nodes } } if raw, err := gitea.MyRepos(); err == nil { if nodes, err := atlas.RepoNodes(raw); err == nil { d.repos = nodes } } 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 } } d.stuck = fetchStuck() liveCache = d return d } // fetchStuck re-derives cad#14's stuck worklist fresh on every liveOverlay // call (no cache beyond the shared liveTTL) — the same "re-derive, never // recall" discipline cad-stuck and cad-babysit both hold to. Read-only: // gitearead.Client has no write method in its package. Degrades to nil // (no badges rendered) on any error or when GITEA_TOKEN is unset, exactly // like every other liveOverlay source — a forge outage costs a missing // overlay, never a broken page. func fetchStuck() []caddomain.StuckFinding { token := os.Getenv("GITEA_TOKEN") if token == "" { return nil } base := os.Getenv("GITEA_BASE") if base == "" || strings.Contains(base, ".svc.cluster.local") { // The in-cluster Gitea service (internal/gitea's default) has no // public HTTPS endpoint for gitearead's client to call with a // bearer token in the same way; cad-stuck's own default (the public // git.d-ma.be) is the correct base here regardless of what // GITEA_BASE is set to for the read-only in-cluster fetchers above. base = "https://git.d-ma.be" } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client := gitearead.New(base, token) cfg := cadapp.DefaultStuckConfig() cfg.BaseURL = base findings, _, err := cadapp.RunStuckQuery(ctx, client, stuckRepos(), cfg) if err != nil { return nil } return findings }