feat: the ci.hanzo.ai dashboard — a view over Hanzo Git, not a second CI
cd.hanzo.ai has been the delivery surface for a while; there was no build
surface. ci.hanzo.ai 404'd and this repo held only the reusable workflow, so
"how is the fleet building" had no answer outside per-repo pages.
This owns no run state. Hanzo Git schedules every job and holds every log; this
reads that and presents it. A CI service with its own run database would put two
answers to "did the build pass" in the fleet, and the one users look at would be
the one that drifts. git.hanzo.ai is the store, ci.hanzo.ai is the view.
Reads /v1/repos/{owner}/{repo}/actions/runs (our Gitea drops the /api prefix).
Scan strategy is repo-search sorted by activity, windowed: the instance mirrors
~1400 repos and almost none built recently, so walking all of them would spend
the whole refresh budget confirming silence. One poller, one cache, bounded
fan-out — N open dashboards cost the forge the same as one, and a status page
must never be what degrades the system it reports on.
A failed poll keeps the last good rows and says so, rather than blanking: an
empty page reads as "nothing is building", which is the opposite of the truth
during an outage. Same reason /healthz is liveness-only and does not gate on
having a snapshot.
⚠ The bug this caught in itself, before shipping: Hanzo Git reports every
finished run as status=completed regardless of outcome, and carries the verdict
in a separate `conclusion`. Bucketing on status alone drew 15 of 20 live runs
red — every success and every cancellation shown as failing. Both fields are now
required to decide a colour. `cancelled` is its own bucket, not a failure:
superseded pushes cancel in-flight runs and they are the largest category on
this fleet, so folding them into red makes a board nobody trusts.
Verified against the live instance: buckets match the raw API exactly —
18 completed/success, 4 completed/cancelled, 3 in_progress + 5 queued = 8
running, 0 failing.
Tenancy is the org slug, the same value Hanzo Git namespaces repos by, IAM
issues in the `owner` claim, and Hanzo CD fences projects with. Filtering here
is that boundary, not a parallel notion of who-sees-what.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# ci — the ci.hanzo.ai dashboard. Pure-Go, no cgo, no assets: the page is
|
||||
# server-rendered from a template compiled into the binary, so the image is the
|
||||
# binary and a CA bundle. Nothing to serve from disk, nothing to go stale
|
||||
# against the code.
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /build
|
||||
# Resolve through the module proxy: proxy.golang.org and sum.golang.org agree
|
||||
# and neither can change under us, which a direct fetch against a moved tag
|
||||
# cannot promise.
|
||||
ENV GOPROXY=https://proxy.golang.org,direct
|
||||
COPY go.mod ./
|
||||
RUN --mount=type=cache,id=ci-gomod,target=/go/pkg/mod go mod download
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=ci-gomod,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /build/ci .
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S hanzo && adduser -S hanzo -G hanzo
|
||||
COPY --from=builder /build/ci /app/ci
|
||||
USER hanzo
|
||||
EXPOSE 8080
|
||||
# Liveness only. Readiness deliberately does not gate on having a snapshot — see
|
||||
# the /healthz comment in main.go: a Hanzo Git outage must render as a dashboard
|
||||
# saying so, not as this pod leaving the load balancer as well.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
||||
ENTRYPOINT ["/app/ci"]
|
||||
@@ -0,0 +1,481 @@
|
||||
// ci — the dashboard behind ci.hanzo.ai.
|
||||
//
|
||||
// It owns no build state. Run truth lives in Hanzo Git (git.hanzo.ai), which
|
||||
// schedules the jobs and holds every log; this reads that and presents it. The
|
||||
// alternative — a CI service with its own run database — would put two answers
|
||||
// to "did the build pass" in the fleet, and the one users look at would be the
|
||||
// one that can drift. So: git.hanzo.ai is the store, ci.hanzo.ai is the view.
|
||||
//
|
||||
// This is the CI half of the pair. cd.hanzo.ai reconciles image pins from
|
||||
// hanzoai/universe and is the delivery view; the two are deliberately separate
|
||||
// surfaces over separate systems, not one console pretending build and deploy
|
||||
// are the same event.
|
||||
//
|
||||
// Tenancy is the same value everywhere: an org slug. Hanzo Git namespaces repos
|
||||
// by org, IAM issues that slug in the `owner` claim, and Hanzo CD fences
|
||||
// projects by it. Filtering here by `org` is therefore the same boundary those
|
||||
// enforce, not a parallel notion of who-sees-what.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
logger.Error("config", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
src := &gitSource{base: cfg.gitBase, token: cfg.gitToken, http: &http.Client{Timeout: 20 * time.Second}}
|
||||
cache := &runCache{}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// One poller, one cache. Every viewer reads the same snapshot, so N open
|
||||
// dashboards cost Hanzo Git exactly as much as one — a dashboard that
|
||||
// fanned each page load into upstream calls is how a status page takes the
|
||||
// system it reports on down.
|
||||
go poll(ctx, logger, src, cache, cfg)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Liveness only: up means "serving". Readiness deliberately does NOT
|
||||
// gate on having a snapshot — a Hanzo Git outage must show as a stale
|
||||
// dashboard saying so, not as ci.hanzo.ai disappearing from the LB too.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("/v1/runs", func(w http.ResponseWriter, r *http.Request) {
|
||||
snap := cache.get()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"runs": filterByOrg(snap.Runs, r.URL.Query().Get("org")),
|
||||
"fetchedAt": snap.FetchedAt,
|
||||
"stale": snap.stale(cfg.staleAfter),
|
||||
"sourceErr": snap.errString(),
|
||||
"repos": snap.Repos,
|
||||
"orgs": orgsOf(snap.Runs),
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
renderDashboard(w, cache.get(), r.URL.Query().Get("org"), cfg)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.listen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
sh, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(sh)
|
||||
}()
|
||||
|
||||
logger.Info("ci dashboard listening", "addr", cfg.listen, "source", cfg.gitBase, "refresh", cfg.refresh.String())
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("serve", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── config ─────────────────────────────
|
||||
|
||||
type config struct {
|
||||
listen string
|
||||
gitBase string
|
||||
gitToken string
|
||||
refresh time.Duration
|
||||
staleAfter time.Duration
|
||||
scanRepos int
|
||||
runsPer int
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
c := config{
|
||||
listen: env("CI_LISTEN", ":8080"),
|
||||
gitBase: strings.TrimRight(env("CI_GIT_BASE", "https://git.hanzo.ai"), "/"),
|
||||
gitToken: os.Getenv("CI_GIT_TOKEN"),
|
||||
scanRepos: envInt("CI_SCAN_REPOS", 60),
|
||||
runsPer: envInt("CI_RUNS_PER_REPO", 8),
|
||||
}
|
||||
c.refresh = time.Duration(envInt("CI_REFRESH_SECONDS", 45)) * time.Second
|
||||
// Stale is a multiple of refresh, not its own knob: the only meaningful
|
||||
// definition of stale is "we have missed several refreshes", and deriving
|
||||
// it means the two can never be configured into contradiction.
|
||||
c.staleAfter = 4 * c.refresh
|
||||
if c.gitToken == "" {
|
||||
return c, errors.New("CI_GIT_TOKEN required (Hanzo Git API token)")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(k string, def int) int {
|
||||
if v, err := strconv.Atoi(os.Getenv(k)); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ───────────────────────────── model ─────────────────────────────
|
||||
|
||||
// Run is the projection of a Hanzo Git workflow run this dashboard shows. It is
|
||||
// deliberately a SUBSET: the upstream object carries a dozen more fields, and
|
||||
// copying them all would make this a second schema to maintain against theirs.
|
||||
type Run struct {
|
||||
ID int64 `json:"id"`
|
||||
Org string `json:"org"`
|
||||
Repo string `json:"repo"`
|
||||
Workflow string `json:"workflow"`
|
||||
Title string `json:"title"`
|
||||
|
||||
// Status and Conclusion are BOTH required to know how a run went, and
|
||||
// reading only one is wrong in a way that looks fine. Status answers
|
||||
// "is it over" (queued | in_progress | completed); Conclusion answers
|
||||
// "how did it end" and is empty until it is over. A view that buckets on
|
||||
// Status alone sees `completed` and cannot tell a pass from a failure —
|
||||
// which is exactly the bug this pair replaced: every finished run,
|
||||
// including successes and cancellations, was being drawn as failing.
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
|
||||
Event string `json:"event"`
|
||||
Branch string `json:"branch"`
|
||||
SHA string `json:"sha"`
|
||||
Actor string `json:"actor"`
|
||||
Number int `json:"number"`
|
||||
URL string `json:"url"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt"`
|
||||
}
|
||||
|
||||
// Duration is zero-valued rather than negative when a run has not finished —
|
||||
// callers render "running", and a negative duration would print as one.
|
||||
func (r Run) Duration() time.Duration {
|
||||
if r.StartedAt.IsZero() || r.EndedAt.IsZero() || r.EndedAt.Before(r.StartedAt) {
|
||||
return 0
|
||||
}
|
||||
return r.EndedAt.Sub(r.StartedAt)
|
||||
}
|
||||
|
||||
type snapshot struct {
|
||||
Runs []Run `json:"runs"`
|
||||
Repos int `json:"repos"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
Err error `json:"-"`
|
||||
}
|
||||
|
||||
func (s snapshot) stale(after time.Duration) bool {
|
||||
return s.FetchedAt.IsZero() || time.Since(s.FetchedAt) > after
|
||||
}
|
||||
|
||||
func (s snapshot) errString() string {
|
||||
if s.Err == nil {
|
||||
return ""
|
||||
}
|
||||
return s.Err.Error()
|
||||
}
|
||||
|
||||
type runCache struct {
|
||||
mu sync.RWMutex
|
||||
snap snapshot
|
||||
}
|
||||
|
||||
func (c *runCache) get() snapshot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.snap
|
||||
}
|
||||
|
||||
// put keeps the LAST GOOD run list when a refresh fails, recording the error
|
||||
// alongside it. A failed poll must not blank the dashboard: "Hanzo Git is
|
||||
// unreachable, here is what we last saw" is strictly more useful than an empty
|
||||
// page, which reads as "nothing is building".
|
||||
func (c *runCache) put(s snapshot) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if s.Err != nil && len(s.Runs) == 0 && len(c.snap.Runs) > 0 {
|
||||
prev := c.snap
|
||||
prev.Err = s.Err
|
||||
c.snap = prev
|
||||
return
|
||||
}
|
||||
c.snap = s
|
||||
}
|
||||
|
||||
// ───────────────────────────── source ─────────────────────────────
|
||||
|
||||
type gitSource struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func (g *gitSource) getJSON(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+g.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := g.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: %s", path, resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
type repoRef struct {
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
// repos returns the most recently ACTIVE repositories. Sorting by activity and
|
||||
// taking a window is the whole scan strategy: the instance mirrors ~1400 repos
|
||||
// and almost none of them built in the last hour, so walking all of them would
|
||||
// spend the entire refresh budget confirming silence.
|
||||
func (g *gitSource) repos(ctx context.Context, limit int) ([]string, error) {
|
||||
var body struct {
|
||||
Data []repoRef `json:"data"`
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sort", "updated")
|
||||
q.Set("order", "desc")
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
if err := g.getJSON(ctx, "/v1/repos/search?"+q.Encode(), &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(body.Data))
|
||||
for _, r := range body.Data {
|
||||
if r.FullName != "" {
|
||||
names = append(names, r.FullName)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
type apiRun struct {
|
||||
ID int64 `json:"id"`
|
||||
DisplayTitle string `json:"display_title"`
|
||||
Path string `json:"path"`
|
||||
Event string `json:"event"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
RunNumber int `json:"run_number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
Actor struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"actor"`
|
||||
}
|
||||
|
||||
func (g *gitSource) runs(ctx context.Context, fullName string, limit int) ([]Run, error) {
|
||||
var body struct {
|
||||
WorkflowRuns []apiRun `json:"workflow_runs"`
|
||||
}
|
||||
path := fmt.Sprintf("/v1/repos/%s/actions/runs?limit=%d", fullName, limit)
|
||||
if err := g.getJSON(ctx, path, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
org, repo := splitFullName(fullName)
|
||||
out := make([]Run, 0, len(body.WorkflowRuns))
|
||||
for _, r := range body.WorkflowRuns {
|
||||
out = append(out, Run{
|
||||
ID: r.ID,
|
||||
Org: org,
|
||||
Repo: repo,
|
||||
Workflow: workflowOf(r.Path),
|
||||
Title: r.DisplayTitle,
|
||||
Status: r.Status,
|
||||
Conclusion: r.Conclusion,
|
||||
Event: r.Event,
|
||||
Branch: r.HeadBranch,
|
||||
SHA: shortSHA(r.HeadSHA),
|
||||
Actor: r.Actor.Login,
|
||||
Number: r.RunNumber,
|
||||
URL: r.HTMLURL,
|
||||
StartedAt: parseTime(r.StartedAt),
|
||||
EndedAt: parseTime(r.CompletedAt),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// poll refreshes the snapshot on an interval, forever.
|
||||
func poll(ctx context.Context, logger *slog.Logger, src *gitSource, cache *runCache, cfg config) {
|
||||
refresh := func() {
|
||||
rctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
names, err := src.repos(rctx, cfg.scanRepos)
|
||||
if err != nil {
|
||||
logger.Warn("repo scan failed", "err", err)
|
||||
cache.put(snapshot{FetchedAt: time.Now().UTC(), Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// Fan out, bounded. The cap is small on purpose: this is a read against
|
||||
// the forge that schedules every build in the fleet, and a dashboard is
|
||||
// never worth degrading it.
|
||||
const workers = 6
|
||||
var (
|
||||
mu sync.Mutex
|
||||
all []Run
|
||||
errs []string
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
jobs := make(chan string)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for name := range jobs {
|
||||
rs, err := src.runs(rctx, name, cfg.runsPer)
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
// A repo with Actions disabled 404s. That is normal and
|
||||
// not worth surfacing as a dashboard-level failure, so
|
||||
// it is counted, not shown.
|
||||
errs = append(errs, name)
|
||||
} else {
|
||||
all = append(all, rs...)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, n := range names {
|
||||
select {
|
||||
case jobs <- n:
|
||||
case <-rctx.Done():
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].StartedAt.After(all[j].StartedAt) })
|
||||
cache.put(snapshot{Runs: all, Repos: len(names) - len(errs), FetchedAt: time.Now().UTC()})
|
||||
logger.Info("refreshed", "repos", len(names), "withRuns", len(names)-len(errs), "runs", len(all))
|
||||
}
|
||||
|
||||
refresh()
|
||||
t := time.NewTicker(cfg.refresh)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
func splitFullName(s string) (org, repo string) {
|
||||
if i := strings.IndexByte(s, '/'); i > 0 {
|
||||
return s[:i], s[i+1:]
|
||||
}
|
||||
return "", s
|
||||
}
|
||||
|
||||
// workflowOf reduces "e2e.yml@refs/heads/main" to "e2e.yml".
|
||||
func workflowOf(path string) string {
|
||||
if i := strings.IndexByte(path, '@'); i > 0 {
|
||||
return path[:i]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func shortSHA(s string) string {
|
||||
if len(s) > 7 {
|
||||
return s[:7]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
// Hanzo Git reports an unset timestamp as the Unix epoch rather than null;
|
||||
// treated as absent so the UI shows "—" instead of 1970.
|
||||
if t.Year() < 2000 {
|
||||
return time.Time{}
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
func filterByOrg(runs []Run, org string) []Run {
|
||||
if org == "" {
|
||||
return runs
|
||||
}
|
||||
out := make([]Run, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
if r.Org == org {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orgsOf(runs []Run) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, r := range runs {
|
||||
if r.Org != "" {
|
||||
seen[r.Org] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for o := range seen {
|
||||
out = append(out, o)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// render.go — the HTML view. Server-rendered on purpose: this page is a table
|
||||
// of build results, and a client-side app would ship a bundle, a fetch layer
|
||||
// and a loading state to show the same rows a second later. The dashboard also
|
||||
// has to be readable when the thing it reports on is broken, which is exactly
|
||||
// when a build pipeline for its own frontend is the wrong dependency.
|
||||
|
||||
func renderDashboard(w http.ResponseWriter, snap snapshot, org string, cfg config) {
|
||||
runs := filterByOrg(snap.Runs, org)
|
||||
if len(runs) > 200 {
|
||||
runs = runs[:200]
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Runs []Run
|
||||
Orgs []string
|
||||
Org string
|
||||
Repos int
|
||||
FetchedAt time.Time
|
||||
Age string
|
||||
Stale bool
|
||||
SourceErr string
|
||||
Source string
|
||||
Counts map[string]int
|
||||
}{
|
||||
Runs: runs,
|
||||
Orgs: orgsOf(snap.Runs),
|
||||
Org: org,
|
||||
Repos: snap.Repos,
|
||||
FetchedAt: snap.FetchedAt,
|
||||
Age: humanAge(snap.FetchedAt),
|
||||
Stale: snap.stale(cfg.staleAfter),
|
||||
SourceErr: snap.errString(),
|
||||
Source: cfg.gitBase,
|
||||
Counts: countByOutcome(runs),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// countByOutcome buckets runs for the summary strip.
|
||||
func countByOutcome(runs []Run) map[string]int {
|
||||
c := map[string]int{"success": 0, "failure": 0, "running": 0, "cancelled": 0}
|
||||
for _, r := range runs {
|
||||
c[outcome(r)]++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// outcome collapses (status, conclusion) into the four states worth a colour.
|
||||
//
|
||||
// Status alone is NOT enough and getting this wrong is silent: Hanzo Git
|
||||
// reports every finished run as `completed` regardless of how it went, so
|
||||
// bucketing on status painted successes and cancellations as failures — on the
|
||||
// live instance that was 15 of 20 runs mislabelled red.
|
||||
//
|
||||
// `cancelled` gets its own bucket rather than folding into failure. On this
|
||||
// fleet cancellations are the single largest category (superseded pushes cancel
|
||||
// the in-flight run), and a board that shows them as broken is a board nobody
|
||||
// trusts, which is worse than no board.
|
||||
func outcome(r Run) string {
|
||||
if !strings.EqualFold(r.Status, "completed") {
|
||||
return "running" // queued | in_progress | waiting | blocked
|
||||
}
|
||||
switch strings.ToLower(r.Conclusion) {
|
||||
case "success":
|
||||
return "success"
|
||||
case "cancelled", "canceled", "skipped":
|
||||
return "cancelled"
|
||||
case "":
|
||||
// Completed with no conclusion should not happen; if it does, say
|
||||
// "running" rather than inventing a verdict the data does not support.
|
||||
return "running"
|
||||
default:
|
||||
return "failure" // failure | timed_out | action_required
|
||||
}
|
||||
}
|
||||
|
||||
func humanAge(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds ago", int(d.Seconds()))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
default:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
}
|
||||
}
|
||||
|
||||
func humanDur(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "—"
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
|
||||
var tmpl = template.Must(template.New("ci").Funcs(template.FuncMap{
|
||||
"outcome": outcome,
|
||||
"dur": func(r Run) string { return humanDur(r.Duration()) },
|
||||
"ago": humanAge,
|
||||
}).Parse(`<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hanzo CI</title>
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<style>
|
||||
:root{--bg:#0b0b0d;--panel:#141417;--line:#25252b;--fg:#e8e8ea;--dim:#8b8b95;
|
||||
--ok:#3fb950;--fail:#f85149;--run:#d29922;--accent:#8B5CF6}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
header{display:flex;align-items:center;gap:16px;padding:16px 24px;
|
||||
border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
h1{margin:0;font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||||
h1 span{color:var(--accent)}
|
||||
.meta{margin-left:auto;color:var(--dim);font-size:12px;text-align:right}
|
||||
.strip{display:flex;gap:8px;padding:16px 24px;flex-wrap:wrap}
|
||||
.chip{padding:6px 12px;border:1px solid var(--line);border-radius:8px;
|
||||
background:var(--panel);font-size:12px;color:var(--dim)}
|
||||
.chip b{color:var(--fg);font-weight:600}
|
||||
.chip.ok b{color:var(--ok)} .chip.fail b{color:var(--fail)} .chip.run b{color:var(--run)}
|
||||
.chip.cancel b{color:var(--dim)}
|
||||
nav{display:flex;gap:6px;padding:0 24px 16px;flex-wrap:wrap}
|
||||
nav a{padding:5px 11px;border:1px solid var(--line);border-radius:999px;
|
||||
background:var(--panel);color:var(--dim);text-decoration:none;font-size:12px}
|
||||
nav a.on{border-color:var(--accent);color:var(--fg)}
|
||||
.warn{margin:0 24px 16px;padding:10px 14px;border:1px solid var(--run);
|
||||
border-radius:8px;background:#221b0c;color:#f0d58c;font-size:13px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{position:sticky;top:0;background:var(--panel);text-align:left;font-size:11px;
|
||||
text-transform:uppercase;letter-spacing:.06em;color:var(--dim);
|
||||
padding:10px 12px;border-bottom:1px solid var(--line);font-weight:600}
|
||||
td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
tr:hover td{background:#111114}
|
||||
a{color:inherit}
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:8px}
|
||||
.dot.success{background:var(--ok)} .dot.failure{background:var(--fail)}
|
||||
.dot.running{background:var(--run);animation:p 1.4s ease-in-out infinite}
|
||||
.dot.cancelled{background:#4a4a52}
|
||||
@keyframes p{50%{opacity:.35}}
|
||||
.repo{font-weight:600}
|
||||
.org{color:var(--dim)}
|
||||
.title{color:var(--dim);max-width:42ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--dim)}
|
||||
.empty{padding:48px 24px;text-align:center;color:var(--dim)}
|
||||
footer{padding:16px 24px;color:var(--dim);font-size:12px;border-top:1px solid var(--line)}
|
||||
@media(max-width:760px){.hide-sm{display:none}}
|
||||
</style></head><body>
|
||||
|
||||
<header>
|
||||
<h1>Hanzo <span>CI</span></h1>
|
||||
<div class="meta">
|
||||
{{.Repos}} repos · refreshed {{.Age}}<br>
|
||||
source {{.Source}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="strip">
|
||||
<span class="chip ok">passing <b>{{index .Counts "success"}}</b></span>
|
||||
<span class="chip fail">failing <b>{{index .Counts "failure"}}</b></span>
|
||||
<span class="chip run">running <b>{{index .Counts "running"}}</b></span>
|
||||
<span class="chip cancel">cancelled <b>{{index .Counts "cancelled"}}</b></span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<a href="/" {{if eq .Org ""}}class="on"{{end}}>all orgs</a>
|
||||
{{range .Orgs}}<a href="/?org={{.}}" {{if eq $.Org .}}class="on"{{end}}>{{.}}</a>{{end}}
|
||||
</nav>
|
||||
|
||||
{{if .Stale}}<div class="warn">
|
||||
Snapshot is stale — last successful refresh {{.Age}}.
|
||||
{{if .SourceErr}}Hanzo Git said: {{.SourceErr}}{{else}}Hanzo Git is not answering.{{end}}
|
||||
These rows are the last good read, not current state.
|
||||
</div>{{end}}
|
||||
|
||||
{{if .Runs}}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Repository</th><th>Workflow</th><th class="hide-sm">Commit</th>
|
||||
<th class="hide-sm">Actor</th><th>Started</th><th>Took</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Runs}}
|
||||
<tr>
|
||||
<td><span class="dot {{outcome .}}"></span><a href="{{.URL}}"><span class="org">{{.Org}}/</span><span class="repo">{{.Repo}}</span></a></td>
|
||||
<td>{{.Workflow}} <span class="mono">#{{.Number}}</span><div class="title">{{.Title}}</div></td>
|
||||
<td class="hide-sm mono">{{.Branch}}@{{.SHA}}<div>{{.Event}}</div></td>
|
||||
<td class="hide-sm mono">{{.Actor}}</td>
|
||||
<td class="mono">{{ago .StartedAt}}</td>
|
||||
<td class="mono">{{dur .}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody></table>
|
||||
{{else}}
|
||||
<div class="empty">
|
||||
No runs in the scanned window.<br>
|
||||
<span class="mono">Builds land here from {{.Source}} — this view holds no state of its own.</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<footer>
|
||||
Build truth lives in Hanzo Git; this is a view over it.
|
||||
Delivery is <a href="https://cd.hanzo.ai">cd.hanzo.ai</a>.
|
||||
</footer>
|
||||
</body></html>`))
|
||||
Reference in New Issue
Block a user