Federated via graft
21 kB
644 lines
1// Package status tracks the outcome of each repo pair's most recent sync
2// pass, keeps a small dashboard over the activity log, and serves both over
3// HTTP — so an operator can tell the daemon is alive and see what it's
4// actually done without reading logs.
5package status
6
7import (
8 "encoding/json"
9 "html/template"
10 "net/http"
11 "net/url"
12 "sort"
13 "strings"
14 "sync"
15 "time"
16
17 "graft/internal/state"
18)
19
20// PairResult is the outcome of one repo pair's most recent pass. An empty
21// error string means that scope is either disabled or succeeded.
22type PairResult struct {
23 Name string `json:"name"`
24 Series string `json:"series"`
25 LastRun time.Time `json:"last_run"`
26 GitError string `json:"git_error,omitempty"`
27 IssuesError string `json:"issues_error,omitempty"`
28 PatchError string `json:"patch_error,omitempty"`
29}
30
31func (r PairResult) ok() bool {
32 return r.GitError == "" && r.IssuesError == "" && r.PatchError == ""
33}
34
35// Tracker holds the latest result per repo pair, safe for concurrent use,
36// plus a handle on the state store for the activity dashboard.
37type Tracker struct {
38 startedAt time.Time
39 store *state.Store
40 sourceURL string
41
42 mu sync.Mutex
43 pairs map[string]PairResult
44}
45
46func NewTracker(store *state.Store, sourceURL string) *Tracker {
47 return &Tracker{startedAt: time.Now(), store: store, sourceURL: sourceURL, pairs: map[string]PairResult{}}
48}
49
50// Record stores the outcome of a pass for one pair. Pass nil for a scope
51// that succeeded or wasn't enabled. series is the dashboard row this pair's
52// status badge groups under (see config.RepoPair.Series).
53func (t *Tracker) Record(name, series string, gitErr, issuesErr, patchErr error) {
54 r := PairResult{Name: name, Series: series, LastRun: time.Now()}
55 if gitErr != nil {
56 r.GitError = gitErr.Error()
57 }
58 if issuesErr != nil {
59 r.IssuesError = issuesErr.Error()
60 }
61 if patchErr != nil {
62 r.PatchError = patchErr.Error()
63 }
64 t.mu.Lock()
65 defer t.mu.Unlock()
66 t.pairs[name] = r
67}
68
69func (t *Tracker) snapshot() []PairResult {
70 t.mu.Lock()
71 defer t.mu.Unlock()
72 out := make([]PairResult, 0, len(t.pairs))
73 for _, r := range t.pairs {
74 out = append(out, r)
75 }
76 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
77 return out
78}
79
80type statusResponse struct {
81 StartedAt time.Time `json:"started_at"`
82 Pairs []PairResult `json:"pairs"`
83}
84
85// Handler serves:
86//
87// GET /healthz - 200 if every pair's last pass was clean, 503 otherwise
88// GET /api/status - the JSON snapshot Record() has been fed
89// GET / - the activity dashboard (calendar + recent commits)
90func (t *Tracker) Handler() http.Handler {
91 mux := http.NewServeMux()
92
93 mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
94 for _, p := range t.snapshot() {
95 if !p.ok() {
96 w.WriteHeader(http.StatusServiceUnavailable)
97 return
98 }
99 }
100 w.WriteHeader(http.StatusOK)
101 })
102
103 mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
104 resp := statusResponse{StartedAt: t.startedAt, Pairs: t.snapshot()}
105 w.Header().Set("Content-Type", "application/json")
106 json.NewEncoder(w).Encode(resp)
107 })
108
109 mux.HandleFunc("/", t.serveDashboard)
110
111 return mux
112}
113
114const dashboardDays = 3
115
116func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) {
117 since := time.Now().UTC().AddDate(0, 0, -(dashboardDays - 1)).Truncate(24 * time.Hour)
118 entries, err := t.store.ActivitySince(since)
119 if err != nil {
120 http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError)
121 return
122 }
123
124 pairs := groupPairStatus(t.snapshot())
125 health := make(map[string]bool, len(pairs))
126 for _, p := range pairs {
127 health[p.Name] = p.OK
128 }
129
130 data := dashboardData{
131 Series: buildSeriesRows(entries, health),
132 Activity: recentActivity(entries),
133 SourceURL: t.sourceURL,
134 }
135 w.Header().Set("Content-Type", "text/html; charset=utf-8")
136 w.Header().Set("Cache-Control", "no-store")
137 if err := dashboardTmpl.Execute(w, data); err != nil {
138 http.Error(w, err.Error(), http.StatusInternalServerError)
139 }
140}
141
142type dashboardData struct {
143 Series []seriesRow
144 Activity []commitLine
145 SourceURL string
146}
147
148// pairStatus is one badge in the dashboard's top status card: one per
149// series (repo), OK only if every pair mirroring that repo's last pass was
150// clean — grouped so a repo with 3 mirrored sides (e.g. two Forgejos plus
151// an external instance) shows one badge, not three.
152type pairStatus struct {
153 Name string
154 OK bool
155}
156
157func groupPairStatus(results []PairResult) []pairStatus {
158 order := []string{}
159 ok := map[string]bool{}
160 for _, r := range results {
161 name := r.Series
162 if name == "" {
163 name = r.Name
164 }
165 if _, seen := ok[name]; !seen {
166 order = append(order, name)
167 ok[name] = true
168 }
169 if !r.ok() {
170 ok[name] = false
171 }
172 }
173 sort.Strings(order)
174 out := make([]pairStatus, 0, len(order))
175 for _, name := range order {
176 out = append(out, pairStatus{Name: name, OK: ok[name]})
177 }
178 return out
179}
180
181// seriesRow is one full-width strip of the activity heatmap: everything
182// mirrored for one underlying repository (grouped across its Forgejo and
183// Radicle sides via config.RepoPair.Series), one cell per actual event —
184// not one per day — so nothing is ever aggregated behind a single tooltip.
185type seriesRow struct {
186 Name string
187 OK bool
188 SubRows []subRow
189}
190
191// subRow is one mirrored side of a series — one real repo (a specific
192// Forgejo instance, or a specific Radicle node) — shown once the series is
193// expanded, with only that side's own events and its own link. A series
194// with 3 mirrored sides (two Forgejos plus Radicle) gets 3 sub-rows, never
195// one link picked arbitrarily to represent all of them.
196type subRow struct {
197 Label string
198 URL string
199 Events []event
200}
201
202// event is one activity_log row: one heatmap cell, one tooltip, one link.
203type event struct {
204 Kind string // "git", "issue", "patch" — drives the cell's color
205 Label string // "Commit", "Issue", "Patch"
206 Text string
207 URL string
208 Server string // which mirrored side this happened on, e.g. "f1", "alice"
209 When string // human-readable timestamp, for the tooltip
210}
211
212type commitLine struct {
213 When string
214 Pair string
215 PairURL string
216 Server string
217 Kind string // "Commit", "Issue", "Patch"
218 Message string
219 URL string
220}
221
222// serverLabel names which side of a series a repo_pair belongs to, e.g.
223// "federation-x-f1" under series "federation-x" becomes "f1". Several pairs
224// share one series row/badge, so without this an operator can't tell which
225// mirrored server (f1, f2, an external instance...) a given event actually
226// happened on. Empty if the pair name doesn't start with its series name.
227func serverLabel(repoPair, series string) string {
228 if series == "" {
229 return ""
230 }
231 suffix := strings.TrimPrefix(repoPair, series+"-")
232 if suffix == repoPair {
233 return ""
234 }
235 return suffix
236}
237
238// hostLabel shortens a URL's host to its canonical name for compact
239// display: the TLD is dropped ("f1.cyberwild.org" -> "f1.cyberwild",
240// "artefacts.bimr.net" -> "artefacts.bimr"), so every server — ours or an
241// external instance's — reads the same way, never a per-pair alias.
242func hostLabel(rawURL string) string {
243 u, err := url.Parse(rawURL)
244 if err != nil || u.Host == "" {
245 return ""
246 }
247 host := u.Host
248 // A Radicle Explorer URL's host is always the shared frontend
249 // (e.g. radicle.cyberwild.org) — the actual node lives in the path,
250 // /nodes/<node-host>/<rid>/... — so different Radicle nodes get
251 // distinct labels instead of collapsing into one "radicle.cyberwild".
252 if segs := strings.Split(strings.TrimPrefix(u.Path, "/"), "/"); len(segs) >= 2 && segs[0] == "nodes" {
253 host = segs[1]
254 }
255 parts := strings.Split(host, ".")
256 if len(parts) > 1 {
257 parts = parts[:len(parts)-1]
258 }
259 return strings.Join(parts, ".")
260}
261
262// buildSeriesRows groups entries by their Series (one row per monitored
263// repo, spanning both its Forgejo and Radicle sides) — Atlassian
264// Statuspage style: one full-width horizontal strip per series. Each entry
265// becomes its own cell, oldest first, so a busy day never hides events
266// behind one shared tooltip.
267func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool) []seriesRow {
268 type subAcc struct {
269 url string
270 events []state.ActivityEntry
271 }
272 type seriesAcc struct {
273 subOrder []string
274 subs map[string]*subAcc
275 }
276 seriesAccs := map[string]*seriesAcc{}
277 var seriesOrder []string
278 for _, e := range entries {
279 name := e.Series
280 if name == "" {
281 name = e.RepoPair
282 }
283 sa, ok := seriesAccs[name]
284 if !ok {
285 sa = &seriesAcc{subs: map[string]*subAcc{}}
286 seriesAccs[name] = sa
287 seriesOrder = append(seriesOrder, name)
288 }
289 server := hostLabel(e.URL)
290 if server == "" {
291 server = serverLabel(e.RepoPair, e.Series)
292 }
293 if server == "" {
294 server = e.RepoPair
295 }
296 sub, ok := sa.subs[server]
297 if !ok {
298 sub = &subAcc{}
299 sa.subs[server] = sub
300 sa.subOrder = append(sa.subOrder, server)
301 }
302 if sub.url == "" {
303 sub.url = repoRootURL(e.URL)
304 }
305 sub.events = append(sub.events, e)
306 }
307 sort.Strings(seriesOrder)
308
309 rows := make([]seriesRow, 0, len(seriesOrder))
310 for _, name := range seriesOrder {
311 sa := seriesAccs[name]
312 subOrder := append([]string(nil), sa.subOrder...)
313 sort.Strings(subOrder)
314
315 subRows := make([]subRow, 0, len(subOrder))
316 for _, label := range subOrder {
317 sub := sa.subs[label]
318 // entries arrive newest-first (see ActivitySince); the
319 // heatmap reads left-to-right as oldest-to-newest.
320 es := make([]state.ActivityEntry, len(sub.events))
321 copy(es, sub.events)
322 sort.Slice(es, func(i, j int) bool { return es[i].OccurredAt.Before(es[j].OccurredAt) })
323 subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: toEvents(es)})
324 }
325
326 ok, known := health[name]
327 if !known {
328 ok = true
329 }
330 rows = append(rows, seriesRow{Name: name, OK: ok, SubRows: subRows})
331 }
332 return rows
333}
334
335func toEvents(es []state.ActivityEntry) []event {
336 out := make([]event, 0, len(es))
337 for _, e := range es {
338 server := hostLabel(e.URL)
339 if server == "" {
340 server = serverLabel(e.RepoPair, e.Series)
341 }
342 out = append(out, event{
343 Kind: e.Kind,
344 Label: kindLabel(e.Kind),
345 Text: e.Summary,
346 URL: e.URL,
347 Server: server,
348 When: e.OccurredAt.Format("Mon, Jan 2, 15:04"),
349 })
350 }
351 return out
352}
353
354func kindLabel(kind string) string {
355 switch kind {
356 case "git":
357 return "Commit"
358 case "issue":
359 return "Issue"
360 case "patch":
361 return "Patch"
362 default:
363 return kind
364 }
365}
366
367// recentActivity lists every mirrored event — commits, issues, and
368// patches/pull requests alike, newest first. graft doesn't mirror stars or
369// other social metadata: its sync scope is git content, issues, and
370// patches/PRs only (see config.SyncScope), so that's what shows up here.
371func recentActivity(entries []state.ActivityEntry) []commitLine {
372 out := make([]commitLine, 0, len(entries))
373 for _, e := range entries {
374 name := e.Series
375 if name == "" {
376 name = e.RepoPair
377 }
378 server := hostLabel(e.URL)
379 if server == "" {
380 server = serverLabel(e.RepoPair, e.Series)
381 }
382 // The (server) parenthetical should point at that specific
383 // server's own repo, not the series' shared Radicle explorer
384 // link (which is fixed to whichever pair logged first).
385 pairURL := repoRootURL(e.URL)
386 if pairURL == "" {
387 pairURL = e.SeriesURL
388 }
389 out = append(out, commitLine{
390 When: e.OccurredAt.Format("Jan 2, 15:04"),
391 Pair: name,
392 PairURL: pairURL,
393 Server: server,
394 Kind: kindLabel(e.Kind),
395 Message: e.Summary,
396 URL: e.URL,
397 })
398 }
399 return out
400}
401
402// repoRootURL trims an event's specific-item URL (a commit, issue, or
403// patch/PR page) back down to its repository root, e.g.
404// ".../owner/repo/commit/<sha>" -> ".../owner/repo". Empty if rawURL is
405// empty or doesn't contain a recognized item path.
406func repoRootURL(rawURL string) string {
407 // Forgejo uses singular "/commit/"; Radicle Explorer uses plural
408 // "/commits/" — both must be recognized, or every Radicle-hosted
409 // commit link falls back to a non-clickable label.
410 for _, marker := range []string{"/commit/", "/commits/", "/issues/", "/pulls/", "/patches/"} {
411 if idx := strings.Index(rawURL, marker); idx >= 0 {
412 return rawURL[:idx]
413 }
414 }
415 return ""
416}
417
418var dashboardTmpl = template.Must(template.New("dashboard").Parse(`<!doctype html>
419<html lang="en">
420<head>
421<meta charset="utf-8">
422<meta name="viewport" content="width=device-width, initial-scale=1">
423<title>graft</title>
424<style>
425 :root {
426 color-scheme: light;
427 --surface: #ffffff;
428 --surface-sunk: #f4f5f7;
429 --text: #172b4d;
430 --text-muted: #6b778c;
431 --border: #dfe1e6;
432 --brand: #0052cc;
433 --brand-dark: #0747a6;
434 --kind-git: #0052cc;
435 --kind-issue: #00875a;
436 --kind-patch: #6554c0;
437 --bad: #de350b;
438 --good: #36b37e;
439 }
440 * { box-sizing: border-box; }
441 body {
442 margin: 0;
443 background: var(--surface-sunk);
444 color: var(--text);
445 font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
446 }
447 main { max-width: 920px; margin: 0 auto; padding: 2.5rem 1.5rem 1rem; }
448
449 .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; }
450 h1 { font-size: 1.25rem; margin: 0; font-weight: 600; }
451 .sub { color: var(--text-muted); font-size: .85rem; }
452
453 .card {
454 background: var(--surface);
455 border: 1px solid var(--border);
456 border-radius: 4px;
457 padding: 1.25rem 1.5rem;
458 margin-bottom: 1.25rem;
459 }
460
461 .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); flex: none; display: inline-block; }
462 .dot.bad { background: var(--bad); }
463
464 .heatmap-head { display: flex; align-items: baseline; justify-content: space-between; }
465 .heatmap-head h2 { margin: 0; }
466 .legend { display: flex; gap: .9rem; margin-bottom: .9rem; }
467 .legend span { display: flex; align-items: center; gap: .35rem; font-size: .74rem; color: var(--text-muted); }
468 .legend .sw { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
469 .legend .sw[data-kind="git"] { background: var(--kind-git); }
470 .legend .sw[data-kind="issue"] { background: var(--kind-issue); }
471 .legend .sw[data-kind="patch"] { background: var(--kind-patch); }
472 .heatmap { display: flex; flex-direction: column; gap: .15rem; }
473 .series-group { border-bottom: 1px solid var(--border); }
474 .series-group:last-child { border-bottom: none; }
475 .series-group[open] { padding-bottom: .5rem; }
476 .series-label {
477 display: flex; align-items: center; gap: .45rem; padding: .55rem 0;
478 cursor: pointer; list-style: none;
479 }
480 .series-label::-webkit-details-marker { display: none; }
481 .series-label::before {
482 content: ""; width: 0; height: 0; flex: none;
483 border-style: solid; border-width: 4px 0 4px 5px;
484 border-color: transparent transparent transparent var(--text-muted);
485 transition: transform .12s ease;
486 }
487 .series-group[open] > .series-label::before { transform: rotate(90deg); }
488 .series-name { font-size: .82rem; font-weight: 600; color: var(--text); }
489 .series-count { font-size: .74rem; color: var(--text-muted); }
490 .subrows { display: flex; flex-direction: column; gap: .5rem; padding-left: 1.3rem; }
491 .subrow { display: flex; align-items: center; gap: .75rem; }
492 .subrow-name {
493 flex: 0 0 11rem; font-size: .78rem; color: var(--text-muted); text-decoration: none;
494 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
495 }
496 a.subrow-name { color: var(--brand); }
497 a.subrow-name:hover { text-decoration: underline; }
498 .days { display: flex; gap: 3px; flex-wrap: wrap; }
499 .cell {
500 flex: none; width: 10px; height: 22px; border-radius: 3px;
501 background: var(--border); position: relative; cursor: default;
502 }
503 .cell[data-kind="git"] { background: var(--kind-git); }
504 .cell[data-kind="issue"] { background: var(--kind-issue); }
505 .cell[data-kind="patch"] { background: var(--kind-patch); }
506
507 .tip {
508 display: none; position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%);
509 background: var(--text); color: #fff; border-radius: 6px;
510 font-size: .78rem; z-index: 20; padding: .5rem 0;
511 box-shadow: 0 4px 12px rgba(9,30,66,.25);
512 width: max-content; max-width: min(320px, 90vw);
513 }
514 .cell:nth-child(-n+2) .tip { left: 0; transform: none; }
515 .cell:nth-last-child(-n+2) .tip { left: auto; right: 0; transform: none; }
516 .cell:hover .tip, .tip:hover { display: block; }
517 .tip .tip-date { padding: 0 .75rem .35rem; font-weight: 600; border-bottom: 1px solid rgba(255,255,255,.15); margin-bottom: .35rem; }
518 .tip a.tip-row {
519 display: flex; gap: .4em; padding: .3rem .75rem; color: #fff; text-decoration: none;
520 white-space: normal; overflow-wrap: anywhere; line-height: 1.35;
521 }
522 .tip a.tip-row:hover { background: rgba(255,255,255,.12); }
523 .tip .tip-kind { color: #b3bac5; flex: none; }
524 .tip .tip-empty { padding: 0 .75rem; color: #b3bac5; }
525
526 h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .9rem; font-weight: 600; }
527 .commits { border-top: 1px solid var(--border); }
528 .commit {
529 display: flex; align-items: center; gap: .75rem; padding: .55rem 0; border-bottom: 1px solid var(--border);
530 font-size: .82rem;
531 }
532 .commit .when { color: var(--text-muted); flex: none; width: 9em; }
533 .commit .kind { color: var(--text-muted); flex: none; width: 4.5em; font-size: .74rem; text-transform: uppercase; letter-spacing: .03em; }
534 .commit .pair {
535 color: var(--text-muted); flex: 0 1 auto; max-width: 22em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
536 text-decoration: none;
537 }
538 .commit a.pair { color: var(--brand); }
539 .commit a.pair:hover { text-decoration: underline; }
540 .commit .msg {
541 flex: 1 1 auto; min-width: 0;
542 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace;
543 color: inherit; text-decoration: none;
544 }
545 .commit a.msg { color: var(--brand); }
546 .commit a.msg:hover { text-decoration: underline; }
547 .empty { color: var(--text-muted); padding: .5rem 0; }
548
549 footer {
550 max-width: 920px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem;
551 color: var(--text-muted); font-size: .78rem; display: flex; gap: 1rem;
552 }
553 footer a { color: var(--text-muted); }
554</style>
555</head>
556<body>
557<main>
558 <div class="top">
559 <h1>graft</h1>
560 <span class="sub">Forgejo <-> Radicle sync — last 3 days</span>
561 </div>
562
563 <div class="card">
564 <div class="heatmap-head">
565 <h2>Activity by repo</h2>
566 <div class="legend">
567 <span><i class="sw" data-kind="git"></i>Commit</span>
568 <span><i class="sw" data-kind="issue"></i>Issue</span>
569 <span><i class="sw" data-kind="patch"></i>Patch</span>
570 </div>
571 </div>
572 <div class="heatmap">
573 {{range .Series}}
574 <details class="series-group">
575 <summary class="series-label">
576 <span class="dot{{if not .OK}} bad{{end}}"></span>
577 <span class="series-name">{{.Name}}</span>
578 <span class="series-count">{{len .SubRows}} mirrored side{{if ne (len .SubRows) 1}}s{{end}}</span>
579 </summary>
580 <div class="subrows">
581 {{range .SubRows}}
582 <div class="subrow">
583 {{if .URL}}
584 <a class="subrow-name" href="{{.URL}}" target="_blank" rel="noopener">{{.Label}}</a>
585 {{else}}
586 <span class="subrow-name">{{.Label}}</span>
587 {{end}}
588 <div class="days">
589 {{range .Events}}
590 <div class="cell" data-kind="{{.Kind}}">
591 <div class="tip">
592 <div class="tip-date">{{.When}}</div>
593 {{if .URL}}
594 <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}</span>{{.Text}}</a>
595 {{else}}
596 <span class="tip-row"><span class="tip-kind">{{.Label}}</span>{{.Text}}</span>
597 {{end}}
598 </div>
599 </div>
600 {{else}}
601 <span class="tip-empty">nothing synced</span>
602 {{end}}
603 </div>
604 </div>
605 {{end}}
606 </div>
607 </details>
608 {{else}}
609 <span class="empty">nothing synced yet</span>
610 {{end}}
611 </div>
612 </div>
613
614 <div class="card">
615 <h2>Recent activity</h2>
616 <div class="commits">
617 {{range .Activity}}
618 <div class="commit">
619 <span class="when">{{.When}}</span>
620 <span class="kind">{{.Kind}}</span>
621 {{if .PairURL}}
622 <a class="pair" href="{{.PairURL}}" target="_blank" rel="noopener">{{.Pair}}{{if .Server}} ({{.Server}}){{end}}</a>
623 {{else}}
624 <span class="pair">{{.Pair}}{{if .Server}} ({{.Server}}){{end}}</span>
625 {{end}}
626 {{if .URL}}
627 <a class="msg" href="{{.URL}}" target="_blank" rel="noopener">{{.Message}}</a>
628 {{else}}
629 <span class="msg">{{.Message}}</span>
630 {{end}}
631 </div>
632 {{else}}
633 <p class="empty">nothing mirrored yet</p>
634 {{end}}
635 </div>
636 </div>
637</main>
638<footer>
639 {{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener">{{.SourceURL}}</a>{{end}}
640 <span>GPLv3</span>
641</footer>
642</body>
643</html>
644`))