// Package status tracks the outcome of each repo pair's most recent sync // pass, keeps a small dashboard over the activity log, and serves both over // HTTP — so an operator can tell the daemon is alive and see what it's // actually done without reading logs. package status import ( "encoding/json" "html/template" "net/http" "net/url" "sort" "strings" "sync" "time" "graft/internal/state" ) // PairResult is the outcome of one repo pair's most recent pass. An empty // error string means that scope is either disabled or succeeded. type PairResult struct { Name string `json:"name"` Series string `json:"series"` LastRun time.Time `json:"last_run"` GitError string `json:"git_error,omitempty"` IssuesError string `json:"issues_error,omitempty"` PatchError string `json:"patch_error,omitempty"` } func (r PairResult) ok() bool { return r.GitError == "" && r.IssuesError == "" && r.PatchError == "" } // Tracker holds the latest result per repo pair, safe for concurrent use, // plus a handle on the state store for the activity dashboard. type Tracker struct { startedAt time.Time store *state.Store sourceURL string mu sync.Mutex pairs map[string]PairResult } func NewTracker(store *state.Store, sourceURL string) *Tracker { return &Tracker{startedAt: time.Now(), store: store, sourceURL: sourceURL, pairs: map[string]PairResult{}} } // Record stores the outcome of a pass for one pair. Pass nil for a scope // that succeeded or wasn't enabled. series is the dashboard row this pair's // status badge groups under (see config.RepoPair.Series). func (t *Tracker) Record(name, series string, gitErr, issuesErr, patchErr error) { r := PairResult{Name: name, Series: series, LastRun: time.Now()} if gitErr != nil { r.GitError = gitErr.Error() } if issuesErr != nil { r.IssuesError = issuesErr.Error() } if patchErr != nil { r.PatchError = patchErr.Error() } t.mu.Lock() defer t.mu.Unlock() t.pairs[name] = r } func (t *Tracker) snapshot() []PairResult { t.mu.Lock() defer t.mu.Unlock() out := make([]PairResult, 0, len(t.pairs)) for _, r := range t.pairs { out = append(out, r) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } type statusResponse struct { StartedAt time.Time `json:"started_at"` Pairs []PairResult `json:"pairs"` } // Handler serves: // // GET /healthz - 200 if every pair's last pass was clean, 503 otherwise // GET /api/status - the JSON snapshot Record() has been fed // GET / - the activity dashboard (calendar + recent commits) func (t *Tracker) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { for _, p := range t.snapshot() { if !p.ok() { w.WriteHeader(http.StatusServiceUnavailable) return } } w.WriteHeader(http.StatusOK) }) mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { resp := statusResponse{StartedAt: t.startedAt, Pairs: t.snapshot()} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) }) mux.HandleFunc("/", t.serveDashboard) return mux } const dashboardDays = 3 func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) { since := time.Now().UTC().AddDate(0, 0, -(dashboardDays - 1)).Truncate(24 * time.Hour) entries, err := t.store.ActivitySince(since) if err != nil { http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError) return } pairs := groupPairStatus(t.snapshot()) health := make(map[string]bool, len(pairs)) for _, p := range pairs { health[p.Name] = p.OK } data := dashboardData{ Series: buildSeriesRows(entries, health), Activity: recentActivity(entries), SourceURL: t.sourceURL, } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := dashboardTmpl.Execute(w, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } type dashboardData struct { Series []seriesRow Activity []commitLine SourceURL string } // pairStatus is one badge in the dashboard's top status card: one per // series (repo), OK only if every pair mirroring that repo's last pass was // clean — grouped so a repo with 3 mirrored sides (e.g. two Forgejos plus // an external instance) shows one badge, not three. type pairStatus struct { Name string OK bool } func groupPairStatus(results []PairResult) []pairStatus { order := []string{} ok := map[string]bool{} for _, r := range results { name := r.Series if name == "" { name = r.Name } if _, seen := ok[name]; !seen { order = append(order, name) ok[name] = true } if !r.ok() { ok[name] = false } } sort.Strings(order) out := make([]pairStatus, 0, len(order)) for _, name := range order { out = append(out, pairStatus{Name: name, OK: ok[name]}) } return out } // seriesRow is one full-width strip of the activity heatmap: everything // mirrored for one underlying repository (grouped across its Forgejo and // Radicle sides via config.RepoPair.Series), one cell per actual event — // not one per day — so nothing is ever aggregated behind a single tooltip. type seriesRow struct { Name string OK bool SubRows []subRow } // subRow is one mirrored side of a series — one real repo (a specific // Forgejo instance, or a specific Radicle node) — shown once the series is // expanded, with only that side's own events and its own link. A series // with 3 mirrored sides (two Forgejos plus Radicle) gets 3 sub-rows, never // one link picked arbitrarily to represent all of them. type subRow struct { Label string URL string Events []event } // event is one activity_log row: one heatmap cell, one tooltip, one link. type event struct { Kind string // "git", "issue", "patch" — drives the cell's color Label string // "Commit", "Issue", "Patch" Text string URL string Server string // which mirrored side this happened on, e.g. "f1", "alice" When string // human-readable timestamp, for the tooltip } type commitLine struct { When string Pair string PairURL string Server string Kind string // "Commit", "Issue", "Patch" Message string URL string } // serverLabel names which side of a series a repo_pair belongs to, e.g. // "federation-x-f1" under series "federation-x" becomes "f1". Several pairs // share one series row/badge, so without this an operator can't tell which // mirrored server (f1, f2, an external instance...) a given event actually // happened on. Empty if the pair name doesn't start with its series name. func serverLabel(repoPair, series string) string { if series == "" { return "" } suffix := strings.TrimPrefix(repoPair, series+"-") if suffix == repoPair { return "" } return suffix } // hostLabel shortens a URL's host to its canonical name for compact // display: the TLD is dropped ("f1.cyberwild.org" -> "f1.cyberwild", // "artefacts.bimr.net" -> "artefacts.bimr"), so every server — ours or an // external instance's — reads the same way, never a per-pair alias. func hostLabel(rawURL string) string { u, err := url.Parse(rawURL) if err != nil || u.Host == "" { return "" } parts := strings.Split(u.Host, ".") if len(parts) > 1 { parts = parts[:len(parts)-1] } return strings.Join(parts, ".") } // buildSeriesRows groups entries by their Series (one row per monitored // repo, spanning both its Forgejo and Radicle sides) — Atlassian // Statuspage style: one full-width horizontal strip per series. Each entry // becomes its own cell, oldest first, so a busy day never hides events // behind one shared tooltip. func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool) []seriesRow { type subAcc struct { url string events []state.ActivityEntry } type seriesAcc struct { subOrder []string subs map[string]*subAcc } seriesAccs := map[string]*seriesAcc{} var seriesOrder []string for _, e := range entries { name := e.Series if name == "" { name = e.RepoPair } sa, ok := seriesAccs[name] if !ok { sa = &seriesAcc{subs: map[string]*subAcc{}} seriesAccs[name] = sa seriesOrder = append(seriesOrder, name) } server := hostLabel(e.URL) if server == "" { server = serverLabel(e.RepoPair, e.Series) } if server == "" { server = e.RepoPair } sub, ok := sa.subs[server] if !ok { sub = &subAcc{} sa.subs[server] = sub sa.subOrder = append(sa.subOrder, server) } if sub.url == "" { sub.url = repoRootURL(e.URL) } sub.events = append(sub.events, e) } sort.Strings(seriesOrder) rows := make([]seriesRow, 0, len(seriesOrder)) for _, name := range seriesOrder { sa := seriesAccs[name] subOrder := append([]string(nil), sa.subOrder...) sort.Strings(subOrder) subRows := make([]subRow, 0, len(subOrder)) for _, label := range subOrder { sub := sa.subs[label] // entries arrive newest-first (see ActivitySince); the // heatmap reads left-to-right as oldest-to-newest. es := make([]state.ActivityEntry, len(sub.events)) copy(es, sub.events) sort.Slice(es, func(i, j int) bool { return es[i].OccurredAt.Before(es[j].OccurredAt) }) subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: toEvents(es)}) } ok, known := health[name] if !known { ok = true } rows = append(rows, seriesRow{Name: name, OK: ok, SubRows: subRows}) } return rows } func toEvents(es []state.ActivityEntry) []event { out := make([]event, 0, len(es)) for _, e := range es { server := hostLabel(e.URL) if server == "" { server = serverLabel(e.RepoPair, e.Series) } out = append(out, event{ Kind: e.Kind, Label: kindLabel(e.Kind), Text: e.Summary, URL: e.URL, Server: server, When: e.OccurredAt.Format("Mon, Jan 2, 15:04"), }) } return out } func kindLabel(kind string) string { switch kind { case "git": return "Commit" case "issue": return "Issue" case "patch": return "Patch" default: return kind } } // recentActivity lists every mirrored event — commits, issues, and // patches/pull requests alike, newest first. graft doesn't mirror stars or // other social metadata: its sync scope is git content, issues, and // patches/PRs only (see config.SyncScope), so that's what shows up here. func recentActivity(entries []state.ActivityEntry) []commitLine { out := make([]commitLine, 0, len(entries)) for _, e := range entries { name := e.Series if name == "" { name = e.RepoPair } server := hostLabel(e.URL) if server == "" { server = serverLabel(e.RepoPair, e.Series) } // The (server) parenthetical should point at that specific // server's own repo, not the series' shared Radicle explorer // link (which is fixed to whichever pair logged first). pairURL := repoRootURL(e.URL) if pairURL == "" { pairURL = e.SeriesURL } out = append(out, commitLine{ When: e.OccurredAt.Format("Jan 2, 15:04"), Pair: name, PairURL: pairURL, Server: server, Kind: kindLabel(e.Kind), Message: e.Summary, URL: e.URL, }) } return out } // repoRootURL trims an event's specific-item URL (a commit, issue, or // patch/PR page) back down to its repository root, e.g. // ".../owner/repo/commit/" -> ".../owner/repo". Empty if rawURL is // empty or doesn't contain a recognized item path. func repoRootURL(rawURL string) string { for _, marker := range []string{"/commit/", "/issues/", "/pulls/", "/patches/"} { if idx := strings.Index(rawURL, marker); idx >= 0 { return rawURL[:idx] } } return "" } var dashboardTmpl = template.Must(template.New("dashboard").Parse(` graft

graft

Forgejo <-> Radicle sync — last 3 days

Activity by repo

Commit Issue Patch
{{range .Series}}
{{.Name}} {{len .SubRows}} mirrored side{{if ne (len .SubRows) 1}}s{{end}}
{{range .SubRows}}
{{if .URL}} {{.Label}} {{else}} {{.Label}} {{end}}
{{range .Events}}
{{.When}}
{{if .URL}} {{.Label}}{{.Text}} {{else}} {{.Label}}{{.Text}} {{end}}
{{else}} nothing synced {{end}}
{{end}}
{{else}} nothing synced yet {{end}}

Recent activity

{{range .Activity}}
{{.When}} {{.Kind}} {{if .PairURL}} {{.Pair}}{{if .Server}} ({{.Server}}){{end}} {{else}} {{.Pair}}{{if .Server}} ({{.Server}}){{end}} {{end}} {{if .URL}} {{.Message}} {{else}} {{.Message}} {{end}}
{{else}}

nothing mirrored yet

{{end}}
`))