Federated via graft
41 kB
1147 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// errText joins whichever scopes actually failed on this pair's last pass
36// into one tooltip-sized message. Empty if the pass was clean.
37func (r PairResult) errText() string {
38 var parts []string
39 if r.GitError != "" {
40 parts = append(parts, "git: "+r.GitError)
41 }
42 if r.IssuesError != "" {
43 parts = append(parts, "issues: "+r.IssuesError)
44 }
45 if r.PatchError != "" {
46 parts = append(parts, "patches: "+r.PatchError)
47 }
48 return strings.Join(parts, " | ")
49}
50
51// ServerRef is one repo pair's declared mirror target — a specific Forgejo
52// instance or a specific Radicle node — as configured, regardless of
53// whether it has ever actually produced an event yet.
54type ServerRef struct {
55 Label string // host, e.g. "f1.cyberwild.org"
56 URL string // repo root on that host
57 Radicle bool // true if this side is a Radicle node
58 AuthorizedIntegration bool // true if this Forgejo side receives pushes directly from another pair's Forgejo, bypassing graft
59 PairName string // the config.RepoPair.Name this side belongs to — links a sub-row back to its PairResult for error surfacing
60}
61
62// Tracker holds the latest result per repo pair, safe for concurrent use,
63// plus a handle on the state store for the activity dashboard.
64type Tracker struct {
65 startedAt time.Time
66 store *state.Store
67 sourceURL string
68
69 mu sync.Mutex
70 pairs map[string]PairResult
71 topology map[string][]ServerRef
72 publicHost string
73 blueskyConfigured map[string]bool
74}
75
76func NewTracker(store *state.Store, sourceURL string) *Tracker {
77 return &Tracker{startedAt: time.Now(), store: store, sourceURL: sourceURL, pairs: map[string]PairResult{}}
78}
79
80// SetTopology declares every mirror target configured for each series, so
81// the dashboard can show a side that's never produced an event yet (e.g. a
82// Radicle node nothing has been pushed to) instead of only sides the
83// activity log happens to mention.
84func (t *Tracker) SetTopology(topology map[string][]ServerRef) {
85 t.mu.Lock()
86 defer t.mu.Unlock()
87 t.topology = topology
88}
89
90// SetPublicHost declares the host graft is reachable at (e.g.
91// graft.cyberwild.org), enabling the "Social bridges" link and the
92// /social page. Call with "" (the default) to leave ActivityPub disabled.
93func (t *Tracker) SetPublicHost(host string) {
94 t.mu.Lock()
95 defer t.mu.Unlock()
96 t.publicHost = host
97}
98
99// SetBlueskyConfigured declares which series have a Bluesky account
100// configured, for the /social page's status badge.
101func (t *Tracker) SetBlueskyConfigured(configured map[string]bool) {
102 t.mu.Lock()
103 defer t.mu.Unlock()
104 t.blueskyConfigured = configured
105}
106
107// Record stores the outcome of a pass for one pair. Pass nil for a scope
108// that succeeded or wasn't enabled. series is the dashboard row this pair's
109// status badge groups under (see config.RepoPair.Series).
110func (t *Tracker) Record(name, series string, gitErr, issuesErr, patchErr error) {
111 r := PairResult{Name: name, Series: series, LastRun: time.Now()}
112 if gitErr != nil {
113 r.GitError = gitErr.Error()
114 }
115 if issuesErr != nil {
116 r.IssuesError = issuesErr.Error()
117 }
118 if patchErr != nil {
119 r.PatchError = patchErr.Error()
120 }
121 t.mu.Lock()
122 defer t.mu.Unlock()
123 t.pairs[name] = r
124}
125
126func (t *Tracker) snapshot() []PairResult {
127 t.mu.Lock()
128 defer t.mu.Unlock()
129 out := make([]PairResult, 0, len(t.pairs))
130 for _, r := range t.pairs {
131 out = append(out, r)
132 }
133 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
134 return out
135}
136
137type statusResponse struct {
138 StartedAt time.Time `json:"started_at"`
139 Pairs []PairResult `json:"pairs"`
140}
141
142// Handler serves:
143//
144// GET /healthz - 200 if every pair's last pass was clean, 503 otherwise
145// GET /api/status - the JSON snapshot Record() has been fed
146// GET / - the activity dashboard (calendar + recent commits)
147func (t *Tracker) Handler() http.Handler {
148 mux := http.NewServeMux()
149
150 mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
151 for _, p := range t.snapshot() {
152 if !p.ok() {
153 w.WriteHeader(http.StatusServiceUnavailable)
154 return
155 }
156 }
157 w.WriteHeader(http.StatusOK)
158 })
159
160 mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
161 resp := statusResponse{StartedAt: t.startedAt, Pairs: t.snapshot()}
162 w.Header().Set("Content-Type", "application/json")
163 json.NewEncoder(w).Encode(resp)
164 })
165
166 mux.HandleFunc("/", t.serveDashboard)
167 mux.HandleFunc("/social", t.serveSocial)
168
169 return mux
170}
171
172const dashboardDays = 3
173
174func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) {
175 since := time.Now().UTC().AddDate(0, 0, -(dashboardDays - 1)).Truncate(24 * time.Hour)
176 entries, err := t.store.ActivitySince(since)
177 if err != nil {
178 http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError)
179 return
180 }
181
182 results := t.snapshot()
183 pairs := groupPairStatus(results)
184 health := make(map[string]bool, len(pairs))
185 for _, p := range pairs {
186 health[p.Name] = p.OK
187 }
188 pairResults := make(map[string]PairResult, len(results))
189 for _, r := range results {
190 pairResults[r.Name] = r
191 }
192
193 t.mu.Lock()
194 topology := t.topology
195 publicHost := t.publicHost
196 t.mu.Unlock()
197
198 data := dashboardData{
199 Series: buildSeriesRows(entries, health, topology, pairResults),
200 Activity: recentActivity(entries),
201 SourceURL: t.sourceURL,
202 SocialEnabled: publicHost != "",
203 }
204 w.Header().Set("Content-Type", "text/html; charset=utf-8")
205 w.Header().Set("Cache-Control", "no-store")
206 if err := dashboardTmpl.Execute(w, data); err != nil {
207 http.Error(w, err.Error(), http.StatusInternalServerError)
208 }
209}
210
211// socialData is what the /social page renders: one row per series
212// describing its ActivityPub and AT Proto/Bluesky bridges. Built
213// incrementally across the v2 milestones — see the plan for what each
214// stage adds (follower counts, delivery timestamps, bridged-comment
215// counts, Bluesky status).
216type socialData struct {
217 PublicHost string
218 Series []socialRow
219 SourceURL string
220}
221
222type socialRow struct {
223 Name string
224 Handle string // "series@host"
225 WebfingerURL string
226 ActorURL string
227 OutboxURL string
228 FollowerCount int
229 BlueskyConfigured bool
230}
231
232func (t *Tracker) serveSocial(w http.ResponseWriter, r *http.Request) {
233 t.mu.Lock()
234 topology := t.topology
235 publicHost := t.publicHost
236 blueskyConfigured := t.blueskyConfigured
237 t.mu.Unlock()
238
239 if publicHost == "" {
240 http.NotFound(w, r)
241 return
242 }
243
244 names := make([]string, 0, len(topology))
245 for name := range topology {
246 names = append(names, name)
247 }
248 sort.Strings(names)
249
250 rows := make([]socialRow, 0, len(names))
251 for _, name := range names {
252 actorURL := "https://" + publicHost + "/actors/" + name
253 followerCount, err := t.store.FollowerCount(name)
254 if err != nil {
255 followerCount = 0
256 }
257 rows = append(rows, socialRow{
258 Name: name,
259 Handle: name + "@" + publicHost,
260 WebfingerURL: "https://" + publicHost + "/.well-known/webfinger?resource=acct:" + name + "@" + publicHost,
261 ActorURL: actorURL,
262 OutboxURL: actorURL + "/outbox",
263 FollowerCount: followerCount,
264 BlueskyConfigured: blueskyConfigured[name],
265 })
266 }
267
268 data := socialData{PublicHost: publicHost, Series: rows, SourceURL: t.sourceURL}
269 w.Header().Set("Content-Type", "text/html; charset=utf-8")
270 w.Header().Set("Cache-Control", "no-store")
271 if err := socialTmpl.Execute(w, data); err != nil {
272 http.Error(w, err.Error(), http.StatusInternalServerError)
273 }
274}
275
276type dashboardData struct {
277 Series []seriesRow
278 Activity []commitLine
279 SourceURL string
280 SocialEnabled bool
281}
282
283// pairStatus is one badge in the dashboard's top status card: one per
284// series (repo), OK only if every pair mirroring that repo's last pass was
285// clean — grouped so a repo with 3 mirrored sides (e.g. two Forgejos plus
286// an external instance) shows one badge, not three.
287type pairStatus struct {
288 Name string
289 OK bool
290}
291
292func groupPairStatus(results []PairResult) []pairStatus {
293 order := []string{}
294 ok := map[string]bool{}
295 for _, r := range results {
296 name := r.Series
297 if name == "" {
298 name = r.Name
299 }
300 if _, seen := ok[name]; !seen {
301 order = append(order, name)
302 ok[name] = true
303 }
304 if !r.ok() {
305 ok[name] = false
306 }
307 }
308 sort.Strings(order)
309 out := make([]pairStatus, 0, len(order))
310 for _, name := range order {
311 out = append(out, pairStatus{Name: name, OK: ok[name]})
312 }
313 return out
314}
315
316// seriesRow is one full-width strip of the activity heatmap: everything
317// mirrored for one underlying repository (grouped across its Forgejo and
318// Radicle sides via config.RepoPair.Series), one cell per actual event —
319// not one per day — so nothing is ever aggregated behind a single tooltip.
320type seriesRow struct {
321 Name string
322 OK bool
323 SubRows []subRow
324}
325
326// subRow is one mirrored side of a series — one real repo (a specific
327// Forgejo instance, or a specific Radicle node) — shown once the series is
328// expanded, with only that side's own events and its own link. A series
329// with 3 mirrored sides (two Forgejos plus Radicle) gets 3 sub-rows, never
330// one link picked arbitrarily to represent all of them.
331type subRow struct {
332 Label string
333 URL string
334 Events []event
335 // Error is set when this side's most recent sync pass actually
336 // failed — surfaced as a visible badge instead of a silently
337 // shorter row. A row with an Error never gets the synthetic
338 // alignment fill below: what you see is only what's confirmed.
339 Error string
340}
341
342// event is one heatmap cell, one tooltip, one link — either a real
343// activity_log row, or (when State is set) a synthetic cell standing in for
344// content this side plainly holds without graft ever having logged a push
345// to it directly: the Forgejo side changes originate on ("source"), or a
346// side that received the content some other way graft doesn't log itself —
347// Radicle's own peer-to-peer gossip, or a Forgejo-to-Forgejo Authorized
348// Integration ("replicated"). Synthetic cells reuse the real event they
349// stand in for (same kind, date, summary) so they read exactly like a
350// normal commit/issue/patch cell, just in a different color, with the
351// tooltip naming where it actually happened.
352type event struct {
353 Kind string // "git", "issue", "patch", "comment" — always drives the cell's color
354 Label string // "Commit", "Issue", "Patch", "Comment"
355 Text string
356 URL string
357 Server string // which mirrored side this happened on, e.g. "f1", "alice"
358 When string // human-readable timestamp, for the tooltip
359 // State and OriginLabel exist only for a synthetic cell — one
360 // standing in for content a side plainly holds without graft having
361 // logged a push there directly (see buildSeriesRows). Never drives
362 // the cell's color (Kind always does); surfaces only as a tooltip
363 // note explaining why this side has no event of its own.
364 State string // "" for a real event; "source" or "replicated" for a synthetic one
365 OriginLabel string // for a synthetic cell, the sub-row label the real event was actually logged on
366 // Origin is set on a real "comment" kind event: where it actually
367 // happened — "Forgejo", "Radicle", "Fediverse (ActivityPub)", or
368 // "Bluesky (AT Proto)". Tooltip note only, same as State/OriginLabel.
369 Origin string
370}
371
372// originLabel turns a stored activity_log.origin value into display
373// text for the tooltip.
374func originLabel(origin string) string {
375 switch origin {
376 case "forgejo":
377 return "Forgejo"
378 case "radicle":
379 return "Radicle"
380 case "activitypub":
381 return "Fediverse (ActivityPub)"
382 case "atproto":
383 return "Bluesky (AT Proto)"
384 default:
385 return ""
386 }
387}
388
389type commitLine struct {
390 When string
391 Pair string
392 PairURL string
393 Server string
394 Kind string // "Commit", "Issue", "Patch"
395 Message string
396 URL string
397}
398
399// serverLabel names which side of a series a repo_pair belongs to, e.g.
400// "federation-x-f1" under series "federation-x" becomes "f1". Several pairs
401// share one series row/badge, so without this an operator can't tell which
402// mirrored server (f1, f2, an external instance...) a given event actually
403// happened on. Empty if the pair name doesn't start with its series name.
404func serverLabel(repoPair, series string) string {
405 if series == "" {
406 return ""
407 }
408 suffix := strings.TrimPrefix(repoPair, series+"-")
409 if suffix == repoPair {
410 return ""
411 }
412 return suffix
413}
414
415// hostLabel shortens a URL's host to its canonical name for compact
416// display: the TLD is dropped ("f1.cyberwild.org" -> "f1.cyberwild",
417// "artefacts.bimr.net" -> "artefacts.bimr"), so every server — ours or an
418// external instance's — reads the same way, never a per-pair alias.
419func hostLabel(rawURL string) string {
420 u, err := url.Parse(rawURL)
421 if err != nil || u.Host == "" {
422 return ""
423 }
424 host := u.Host
425 // A Radicle Explorer URL's host is always the shared frontend
426 // (e.g. radicle.cyberwild.org) — the actual node lives in the path,
427 // /nodes/<node-host>/<rid>/... — so different Radicle nodes get
428 // distinct labels instead of collapsing into one shared one.
429 if segs := strings.Split(strings.TrimPrefix(u.Path, "/"), "/"); len(segs) >= 2 && segs[0] == "nodes" {
430 host = segs[1]
431 }
432 return host
433}
434
435// buildSeriesRows groups entries by their Series (one row per monitored
436// repo, spanning both its Forgejo and Radicle sides) — Atlassian
437// Statuspage style: one full-width horizontal strip per series. Each entry
438// becomes its own cell, oldest first, so a busy day never hides events
439// behind one shared tooltip.
440func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool, topology map[string][]ServerRef, pairResults map[string]PairResult) []seriesRow {
441 type subAcc struct {
442 url string
443 events []state.ActivityEntry
444 radicle bool
445 ai bool
446 // err is this side's live sync-error message (empty if its last
447 // pass was clean). Set from pairResults via the topology's
448 // PairName, so it reflects reality even before this side has
449 // ever logged an event of its own.
450 err string
451 // byKey indexes events by dedupKey — built once per side right
452 // before rendering, see buildSeriesRows.
453 byKey map[string]state.ActivityEntry
454 }
455 type seriesAcc struct {
456 subOrder []string
457 subs map[string]*subAcc
458 }
459 seriesAccs := map[string]*seriesAcc{}
460 var seriesOrder []string
461
462 ensureSeries := func(name string) *seriesAcc {
463 sa, ok := seriesAccs[name]
464 if !ok {
465 sa = &seriesAcc{subs: map[string]*subAcc{}}
466 seriesAccs[name] = sa
467 seriesOrder = append(seriesOrder, name)
468 }
469 return sa
470 }
471 ensureSub := func(sa *seriesAcc, label string) *subAcc {
472 sub, ok := sa.subs[label]
473 if !ok {
474 sub = &subAcc{}
475 sa.subs[label] = sub
476 sa.subOrder = append(sa.subOrder, label)
477 }
478 return sub
479 }
480
481 // Seed every configured mirror target first, so a side that has never
482 // produced an event (e.g. a Radicle node nothing has been pushed to
483 // yet) still gets its own row instead of being invisible.
484 for name, refs := range topology {
485 sa := ensureSeries(name)
486 for _, ref := range refs {
487 sub := ensureSub(sa, ref.Label)
488 if sub.url == "" {
489 sub.url = ref.URL
490 }
491 sub.radicle = ref.Radicle
492 sub.ai = ref.AuthorizedIntegration
493 if pr, ok := pairResults[ref.PairName]; ok {
494 sub.err = pr.errText()
495 }
496 }
497 }
498
499 for _, e := range entries {
500 name := e.Series
501 if name == "" {
502 name = e.RepoPair
503 }
504 sa := ensureSeries(name)
505 server := hostLabel(e.URL)
506 if server == "" {
507 server = serverLabel(e.RepoPair, e.Series)
508 }
509 if server == "" {
510 server = e.RepoPair
511 }
512 sub := ensureSub(sa, server)
513 if sub.url == "" {
514 sub.url = repoRootURL(e.URL)
515 }
516 sub.events = append(sub.events, e)
517 }
518 sort.Strings(seriesOrder)
519
520 rows := make([]seriesRow, 0, len(seriesOrder))
521 for _, name := range seriesOrder {
522 sa := seriesAccs[name]
523 subOrder := append([]string(nil), sa.subOrder...)
524 sort.Strings(subOrder)
525
526 // distinctEvents dedupes every event logged anywhere in the series
527 // down to one representative per real underlying commit/issue/patch
528 // — keyed by commit SHA for git (content-addressed, stable across
529 // every mirror), or by (kind, summary) otherwise — keeping
530 // whichever occurrence was logged earliest, since that's the
531 // closest graft gets to "when this actually entered the series".
532 // A series with at least one logged event anywhere means every
533 // currently-silent side already holds this content too — either
534 // as the origin, or received via a path graft doesn't log itself
535 // (Radicle gossip, an Authorized Integration) — so those sides
536 // get synthetic cells built from this map instead of the usual
537 // per-event ones. Only a series with zero events anywhere stays
538 // genuinely empty.
539 type rep struct {
540 entry state.ActivityEntry
541 label string
542 }
543 reps := map[string]rep{}
544 for _, label := range subOrder {
545 for _, e := range sa.subs[label].events {
546 key := dedupKey(e.Kind, e.URL, e.Summary)
547 cur, ok := reps[key]
548 if !ok || e.OccurredAt.Before(cur.entry.OccurredAt) {
549 reps[key] = rep{entry: e, label: label}
550 }
551 }
552 }
553 repKeys := make([]string, 0, len(reps))
554 for k := range reps {
555 repKeys = append(repKeys, k)
556 }
557 sort.Slice(repKeys, func(i, j int) bool {
558 return reps[repKeys[i]].entry.OccurredAt.Before(reps[repKeys[j]].entry.OccurredAt)
559 })
560
561 // byKey lets the render loop below ask "did this specific side log
562 // this specific event itself?" in O(1), so a real cell is used
563 // wherever one exists and a synthetic one only fills the gaps —
564 // never the other way around.
565 for _, sub := range sa.subs {
566 sub.byKey = make(map[string]state.ActivityEntry, len(sub.events))
567 for _, e := range sub.events {
568 sub.byKey[dedupKey(e.Kind, e.URL, e.Summary)] = e
569 }
570 }
571
572 subRows := make([]subRow, 0, len(subOrder))
573 for _, label := range subOrder {
574 sub := sa.subs[label]
575
576 if sub.err != "" {
577 // A side with a live sync error doesn't get the alignment
578 // fill below — showing it as "caught up" would hide the
579 // exact problem this dashboard exists to surface. What's
580 // rendered is only what this side has actually confirmed
581 // itself, so a real gap stays visible, backed by the
582 // error badge the template renders from subRow.Error.
583 es := make([]state.ActivityEntry, len(sub.events))
584 copy(es, sub.events)
585 sort.Slice(es, func(i, j int) bool { return es[i].OccurredAt.Before(es[j].OccurredAt) })
586 subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: toEvents(es), Error: sub.err})
587 continue
588 }
589
590 if len(reps) == 0 {
591 subRows = append(subRows, subRow{Label: label, URL: sub.url})
592 continue
593 }
594
595 // A healthy side (no live error) always renders the full,
596 // series-wide set of distinct events in the same order, so
597 // two sides genuinely holding the same content always look
598 // identical — real cells wherever this side logged the event
599 // itself, synthetic ones filling in the rest (received via
600 // Radicle gossip, an Authorized Integration, or simply not
601 // the side that happened to get logged for that push).
602 cellState := "source"
603 if sub.ai || sub.radicle {
604 cellState = "replicated"
605 }
606 cells := make([]event, 0, len(repKeys))
607 for _, k := range repKeys {
608 if real, ok := sub.byKey[k]; ok {
609 cells = append(cells, toEvent(real))
610 continue
611 }
612 r := reps[k]
613 cells = append(cells, event{
614 Kind: r.entry.Kind,
615 Label: kindLabel(r.entry.Kind),
616 Text: r.entry.Summary,
617 URL: reconstructLink(sub.url, r.entry.Kind, r.entry.URL),
618 Server: label,
619 When: r.entry.OccurredAt.Format("Mon, Jan 2, 15:04"),
620 State: cellState,
621 OriginLabel: r.label,
622 })
623 }
624 subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: cells})
625 }
626
627 ok, known := health[name]
628 if !known {
629 ok = true
630 }
631 rows = append(rows, seriesRow{Name: name, OK: ok, SubRows: subRows})
632 }
633 return rows
634}
635
636func toEvent(e state.ActivityEntry) event {
637 server := hostLabel(e.URL)
638 if server == "" {
639 server = serverLabel(e.RepoPair, e.Series)
640 }
641 return event{
642 Kind: e.Kind,
643 Label: kindLabel(e.Kind),
644 Text: e.Summary,
645 URL: e.URL,
646 Server: server,
647 When: e.OccurredAt.Format("Mon, Jan 2, 15:04"),
648 Origin: originLabel(e.Origin),
649 }
650}
651
652func toEvents(es []state.ActivityEntry) []event {
653 out := make([]event, 0, len(es))
654 for _, e := range es {
655 out = append(out, toEvent(e))
656 }
657 return out
658}
659
660func kindLabel(kind string) string {
661 switch kind {
662 case "git":
663 return "Commit"
664 case "issue":
665 return "Issue"
666 case "patch":
667 return "Patch"
668 case "comment":
669 return "Comment"
670 default:
671 return kind
672 }
673}
674
675// recentActivityLimit caps how many rows the dashboard's recent-activity
676// list renders. Without it, a busy sync window (the 3-day ActivitySince
677// lookback in dashboardData) can hand recentActivity thousands of entries —
678// once observed at 16,621 rows, a 10.6MB page taking 3.1s to load — even
679// though the list is only ever meant to show a short "what just happened"
680// glance, not a full history. Capping the render, not the query: entries
681// are already newest-first, so this keeps the most recent ones.
682const recentActivityLimit = 10
683
684// recentActivity lists the most recent mirrored events — commits, issues,
685// and patches/pull requests alike, newest first, capped at
686// recentActivityLimit. graft doesn't mirror stars or other social
687// metadata: its sync scope is git content, issues, and patches/PRs only
688// (see config.SyncScope), so that's what shows up here.
689func recentActivity(entries []state.ActivityEntry) []commitLine {
690 if len(entries) > recentActivityLimit {
691 entries = entries[:recentActivityLimit]
692 }
693 out := make([]commitLine, 0, len(entries))
694 for _, e := range entries {
695 name := e.Series
696 if name == "" {
697 name = e.RepoPair
698 }
699 server := hostLabel(e.URL)
700 if server == "" {
701 server = serverLabel(e.RepoPair, e.Series)
702 }
703 // The (server) parenthetical should point at that specific
704 // server's own repo, not the series' shared Radicle explorer
705 // link (which is fixed to whichever pair logged first).
706 pairURL := repoRootURL(e.URL)
707 if pairURL == "" {
708 pairURL = e.SeriesURL
709 }
710 out = append(out, commitLine{
711 When: e.OccurredAt.Format("Jan 2, 15:04"),
712 Pair: name,
713 PairURL: pairURL,
714 Server: server,
715 Kind: kindLabel(e.Kind),
716 Message: e.Summary,
717 URL: e.URL,
718 })
719 }
720 return out
721}
722
723// repoRootURL trims an event's specific-item URL (a commit, issue, or
724// patch/PR page) back down to its repository root, e.g.
725// ".../owner/repo/commit/<sha>" -> ".../owner/repo". Empty if rawURL is
726// empty or doesn't contain a recognized item path.
727func repoRootURL(rawURL string) string {
728 // Forgejo uses singular "/commit/"; Radicle Explorer uses plural
729 // "/commits/" — both must be recognized, or every Radicle-hosted
730 // commit link falls back to a non-clickable label.
731 for _, marker := range []string{"/commit/", "/commits/", "/issues/", "/pulls/", "/patches/"} {
732 if idx := strings.Index(rawURL, marker); idx >= 0 {
733 return rawURL[:idx]
734 }
735 }
736 return ""
737}
738
739// commitSHA pulls the trailing commit hash out of a git-item URL, e.g.
740// ".../owner/repo/commit/<sha>" or ".../repo/commits/<sha>" -> "<sha>".
741// Empty if rawURL isn't a recognized commit link.
742func commitSHA(rawURL string) string {
743 for _, marker := range []string{"/commit/", "/commits/"} {
744 idx := strings.Index(rawURL, marker)
745 if idx < 0 {
746 continue
747 }
748 sha := rawURL[idx+len(marker):]
749 if i := strings.IndexAny(sha, "/?#"); i >= 0 {
750 sha = sha[:i]
751 }
752 return sha
753 }
754 return ""
755}
756
757// dedupKey identifies "the same underlying event" across every mirror it
758// got logged on. A commit is content-addressed — its SHA is identical on
759// every side it's mirrored to — so that's the stable key for git events.
760// Issues and patches get local, per-instance IDs that aren't portable
761// across hosts, so their summary text is the best available stand-in.
762func dedupKey(kind, url, summary string) string {
763 if sha := commitSHA(url); sha != "" {
764 return kind + ":" + sha
765 }
766 return kind + ":" + summary
767}
768
769// reconstructLink builds a same-host link for a synthetic cell: a commit's
770// SHA is identical everywhere it's mirrored, so subURL (this side's own
771// repo root) plus the right per-host item path reaches the same commit on
772// this side specifically, rather than sending the reader to the side it
773// was actually logged on. Falls back to the original event's own URL for
774// anything that isn't a reconstructible git link — an issue or patch ID
775// isn't portable across hosts, so linking to where it's actually visible
776// beats guessing.
777func reconstructLink(subURL, kind, originURL string) string {
778 sha := commitSHA(originURL)
779 if kind != "git" || sha == "" || subURL == "" {
780 return originURL
781 }
782 if strings.Contains(subURL, "/nodes/") {
783 return subURL + "/commits/" + sha
784 }
785 return subURL + "/commit/" + sha
786}
787
788var dashboardTmpl = template.Must(template.New("dashboard").Parse(`<!doctype html>
789<html lang="en">
790<head>
791<meta charset="utf-8">
792<meta name="viewport" content="width=device-width, initial-scale=1">
793<title>graft</title>
794<style>
795 :root {
796 color-scheme: light;
797 --surface: #ffffff;
798 --surface-sunk: #f4f5f7;
799 --text: #172b4d;
800 --text-muted: #6b778c;
801 --border: #dfe1e6;
802 --brand: #0052cc;
803 --brand-dark: #0747a6;
804 --kind-git: #0052cc;
805 --kind-issue: #00875a;
806 --kind-patch: #6554c0;
807 --kind-comment: #ff991f;
808 --bad: #de350b;
809 --good: #36b37e;
810 }
811 * { box-sizing: border-box; }
812 body {
813 margin: 0;
814 background: var(--surface-sunk);
815 color: var(--text);
816 font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
817 }
818 main { max-width: 920px; margin: 0 auto; padding: 2.5rem 1.5rem 1rem; }
819
820 .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; }
821 .top > div { display: flex; align-items: baseline; gap: .6rem; }
822 h1 { font-size: 1.25rem; margin: 0; font-weight: 600; }
823 .sub { color: var(--text-muted); font-size: .85rem; }
824 .nav-link { color: var(--brand); font-size: .82rem; text-decoration: none; font-weight: 500; }
825 .nav-link:hover { text-decoration: underline; }
826
827 .card {
828 background: var(--surface);
829 border: 1px solid var(--border);
830 border-radius: 4px;
831 padding: 1.25rem 1.5rem;
832 margin-bottom: 1.25rem;
833 }
834
835 .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); flex: none; display: inline-block; }
836 .dot.bad { background: var(--bad); }
837
838 .heatmap-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: .9rem; }
839 .heatmap-head h2 { margin: 0; }
840 .legend { display: flex; gap: .9rem; margin-bottom: .9rem; flex-wrap: wrap; }
841 .fed-actions { display: flex; gap: .5rem; margin-bottom: .9rem; }
842 .btn-outline {
843 font-size: .76rem; font-weight: 600; color: var(--brand); border: 1px solid var(--brand);
844 border-radius: 4px; padding: .3rem .65rem; text-decoration: none; white-space: nowrap;
845 }
846 .btn-outline:hover { background: var(--brand); color: #fff; }
847 .legend span { display: flex; align-items: center; gap: .35rem; font-size: .74rem; color: var(--text-muted); }
848 .legend .sw { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
849 .legend .sw[data-kind="git"] { background: var(--kind-git); }
850 .legend .sw[data-kind="issue"] { background: var(--kind-issue); }
851 .legend .sw[data-kind="patch"] { background: var(--kind-patch); }
852 .legend .sw[data-kind="comment"] { background: var(--kind-comment); }
853 .heatmap { display: flex; flex-direction: column; gap: .15rem; }
854 .series-group { border-bottom: 1px solid var(--border); }
855 .series-group:last-child { border-bottom: none; }
856 .series-group[open] { padding-bottom: .5rem; }
857 .series-label {
858 display: flex; align-items: center; gap: .45rem; padding: .55rem 0;
859 cursor: pointer; list-style: none;
860 }
861 .series-label::-webkit-details-marker { display: none; }
862 .series-label::before {
863 content: ""; width: 0; height: 0; flex: none;
864 border-style: solid; border-width: 4px 0 4px 5px;
865 border-color: transparent transparent transparent var(--text-muted);
866 transition: transform .12s ease;
867 }
868 .series-group[open] > .series-label::before { transform: rotate(90deg); }
869 .series-name { font-size: .82rem; font-weight: 600; color: var(--text); }
870 .series-count { font-size: .74rem; color: var(--text-muted); }
871 .subrows { display: flex; flex-direction: column; gap: .5rem; padding-left: 1.3rem; }
872 .subrow { display: flex; align-items: center; gap: .75rem; }
873 .subrow-name {
874 flex: 0 0 11rem; font-size: .78rem; color: var(--text-muted); text-decoration: none;
875 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
876 }
877 a.subrow-name { color: var(--brand); }
878 a.subrow-name:hover { text-decoration: underline; }
879 .subrow-err {
880 flex: none; width: 10px; height: 10px; border-radius: 3px;
881 background: var(--bad); cursor: default; position: relative;
882 }
883 .subrow-err .tip { min-width: 16rem; white-space: normal; }
884 .subrow-err:hover .tip { display: block; }
885 .days { display: flex; gap: 3px; flex-wrap: wrap; }
886 .cell {
887 flex: none; width: 10px; height: 22px; border-radius: 3px;
888 background: var(--border); position: relative; cursor: default;
889 }
890 .cell[data-kind="git"] { background: var(--kind-git); }
891 .cell[data-kind="issue"] { background: var(--kind-issue); }
892 .cell[data-kind="patch"] { background: var(--kind-patch); }
893 .cell[data-kind="comment"] { background: var(--kind-comment); }
894
895 .tip {
896 display: none; position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%);
897 background: var(--text); color: #fff; border-radius: 6px;
898 font-size: .78rem; z-index: 20; padding: .5rem 0;
899 box-shadow: 0 4px 12px rgba(9,30,66,.25);
900 width: max-content; max-width: min(320px, 90vw);
901 }
902 .cell:nth-child(-n+2) .tip { left: 0; transform: none; }
903 .cell:nth-last-child(-n+2) .tip { left: auto; right: 0; transform: none; }
904 .cell:hover .tip, .tip:hover { display: block; }
905 .tip .tip-date { padding: 0 .75rem .35rem; font-weight: 600; border-bottom: 1px solid rgba(255,255,255,.15); margin-bottom: .35rem; }
906 .tip a.tip-row {
907 display: flex; gap: .4em; padding: .3rem .75rem; color: #fff; text-decoration: none;
908 white-space: normal; overflow-wrap: anywhere; line-height: 1.35;
909 }
910 .tip a.tip-row:hover { background: rgba(255,255,255,.12); }
911 .tip .tip-kind { color: #b3bac5; flex: none; }
912 .tip .tip-empty { padding: 0 .75rem; color: #b3bac5; }
913 .tip .tip-note {
914 padding: .35rem .75rem 0; margin-top: .3rem; border-top: 1px solid rgba(255,255,255,.15);
915 color: #b3bac5; font-size: .7rem; line-height: 1.4;
916 }
917
918 h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .9rem; font-weight: 600; }
919 .commits { border-top: 1px solid var(--border); }
920 .commit {
921 display: flex; align-items: center; gap: .75rem; padding: .55rem 0; border-bottom: 1px solid var(--border);
922 font-size: .82rem;
923 }
924 .commit .when { color: var(--text-muted); flex: none; width: 9em; }
925 .commit .kind { color: var(--text-muted); flex: none; width: 4.5em; font-size: .74rem; text-transform: uppercase; letter-spacing: .03em; }
926 .commit .pair {
927 color: var(--text-muted); flex: 0 1 auto; max-width: 22em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
928 text-decoration: none;
929 }
930 .commit a.pair { color: var(--brand); }
931 .commit a.pair:hover { text-decoration: underline; }
932 .commit .msg {
933 flex: 1 1 auto; min-width: 0;
934 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace;
935 color: inherit; text-decoration: none;
936 }
937 .commit a.msg { color: var(--brand); }
938 .commit a.msg:hover { text-decoration: underline; }
939 .empty { color: var(--text-muted); padding: .5rem 0; }
940
941 footer {
942 max-width: 920px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem;
943 color: var(--text-muted); font-size: .78rem; display: flex; gap: 1rem;
944 }
945 footer a { color: var(--text-muted); }
946</style>
947</head>
948<body>
949<main>
950 <div class="top">
951 <div>
952 <h1>graft</h1>
953 <span class="sub">Forgejos & Radicles syncs</span>
954 </div>
955 {{if .SocialEnabled}}<a class="nav-link" href="/social">Social IDs →</a>{{end}}
956 </div>
957
958 <div class="card">
959 <div class="heatmap-head">
960 <div class="legend">
961 <span><i class="sw" data-kind="git"></i>Commit</span>
962 <span><i class="sw" data-kind="issue"></i>Issue</span>
963 <span><i class="sw" data-kind="patch"></i>Patch</span>
964 <span><i class="sw" data-kind="comment"></i>Comment</span>
965 </div>
966 <div class="fed-actions">
967 <a class="btn-outline" href="/add-peer">+ Add Forgejo peer</a>
968 <a class="btn-outline" href="/add-radicle-peer">+ Add Radicle peer</a>
969 <a class="btn-outline" href="/new-repo">+ New repo</a>
970 </div>
971 </div>
972 <div class="heatmap">
973 {{range .Series}}
974 <details class="series-group">
975 <summary class="series-label">
976 <span class="dot{{if not .OK}} bad{{end}}"></span>
977 <span class="series-name">{{.Name}}</span>
978 <span class="series-count">{{len .SubRows}} mirror{{if ne (len .SubRows) 1}}s{{end}}</span>
979 </summary>
980 <div class="subrows">
981 {{range .SubRows}}
982 <div class="subrow">
983 {{if .Error}}
984 <span class="subrow-err"><div class="tip"><div class="tip-date">last pass failed</div><div class="tip-note">{{.Error}}</div></div></span>
985 {{end}}
986 {{if .URL}}
987 <a class="subrow-name" href="{{.URL}}" target="_blank" rel="noopener">{{.Label}}</a>
988 {{else}}
989 <span class="subrow-name">{{.Label}}</span>
990 {{end}}
991 <div class="days">
992 {{range .Events}}
993 <div class="cell" data-kind="{{.Kind}}">
994 <div class="tip">
995 <div class="tip-date">{{.When}}</div>
996 {{if .URL}}
997 <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}</span>{{.Text}}</a>
998 {{else}}
999 <span class="tip-row"><span class="tip-kind">{{.Label}}</span>{{.Text}}</span>
1000 {{end}}
1001 {{if eq .State "source"}}
1002 <div class="tip-note">origin of this content — mirrored out from here, via {{.OriginLabel}}</div>
1003 {{else if eq .State "replicated"}}
1004 <div class="tip-note">replicated here without a graft-logged push — originally pushed via {{.OriginLabel}}, {{.When}}</div>
1005 {{end}}
1006 {{if .Origin}}
1007 <div class="tip-note">via {{.Origin}}</div>
1008 {{end}}
1009 </div>
1010 </div>
1011 {{else}}
1012 <span class="tip-empty">nothing synced</span>
1013 {{end}}
1014 </div>
1015 </div>
1016 {{end}}
1017 </div>
1018 </details>
1019 {{else}}
1020 <span class="empty">nothing synced yet</span>
1021 {{end}}
1022 </div>
1023 </div>
1024
1025 <div class="card">
1026 <div class="heatmap-head">
1027 <h2>Recent activity</h2>
1028 <span class="sub">last 10 events</span>
1029 </div>
1030 <div class="commits">
1031 {{range .Activity}}
1032 <div class="commit">
1033 <span class="when">{{.When}}</span>
1034 <span class="kind">{{.Kind}}</span>
1035 {{if .PairURL}}
1036 <a class="pair" href="{{.PairURL}}" target="_blank" rel="noopener">{{.Pair}}{{if .Server}} ({{.Server}}){{end}}</a>
1037 {{else}}
1038 <span class="pair">{{.Pair}}{{if .Server}} ({{.Server}}){{end}}</span>
1039 {{end}}
1040 {{if .URL}}
1041 <a class="msg" href="{{.URL}}" target="_blank" rel="noopener">{{.Message}}</a>
1042 {{else}}
1043 <span class="msg">{{.Message}}</span>
1044 {{end}}
1045 </div>
1046 {{else}}
1047 <p class="empty">nothing mirrored yet</p>
1048 {{end}}
1049 </div>
1050 </div>
1051</main>
1052<footer>
1053 {{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener">{{.SourceURL}}</a>{{end}}
1054 <a href="/share/new">Share a secret, once →</a>
1055 <a href="/admin/pending">Pending requests →</a>
1056 <span>GPLv3</span>
1057</footer>
1058</body>
1059</html>
1060`))
1061
1062var socialTmpl = template.Must(template.New("social").Parse(`<!doctype html>
1063<html lang="en">
1064<head>
1065<meta charset="utf-8">
1066<meta name="viewport" content="width=device-width, initial-scale=1">
1067<title>graft — social IDs</title>
1068<style>
1069 :root {
1070 color-scheme: light;
1071 --surface: #ffffff;
1072 --surface-sunk: #f4f5f7;
1073 --text: #172b4d;
1074 --text-muted: #6b778c;
1075 --border: #dfe1e6;
1076 --brand: #0052cc;
1077 }
1078 * { box-sizing: border-box; }
1079 body {
1080 margin: 0; background: var(--surface-sunk); color: var(--text);
1081 font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1082 }
1083 main { max-width: 760px; margin: 0 auto; padding: 2.5rem 1.5rem 1rem; }
1084 .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; }
1085 .top > div { display: flex; align-items: baseline; gap: .6rem; }
1086 h1 { font-size: 1.25rem; margin: 0; font-weight: 600; }
1087 .sub { color: var(--text-muted); font-size: .85rem; }
1088 .nav-link { color: var(--brand); font-size: .82rem; text-decoration: none; font-weight: 500; }
1089 .nav-link:hover { text-decoration: underline; }
1090 .card {
1091 background: var(--surface); border: 1px solid var(--border); border-radius: 4px;
1092 padding: 1.25rem 1.5rem; margin-bottom: 1.25rem;
1093 }
1094 .series-block { padding: .75rem 0; border-bottom: 1px solid var(--border); }
1095 .series-block:last-child { border-bottom: none; }
1096 .handle { font-size: .95rem; font-weight: 600; color: var(--text); }
1097 h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: .6rem 0 .4rem; font-weight: 600; }
1098 .endpoints { display: flex; flex-direction: column; gap: .3rem; }
1099 .endpoints a { font-size: .8rem; color: var(--brand); text-decoration: none; font-family: ui-monospace, monospace; }
1100 .endpoints a:hover { text-decoration: underline; }
1101 .badge {
1102 display: inline-block; font-size: .72rem; color: var(--text-muted); background: var(--surface-sunk);
1103 border: 1px solid var(--border); border-radius: 3px; padding: .15rem .5rem; margin-top: .4rem;
1104 }
1105 .empty { color: var(--text-muted); padding: .5rem 0; }
1106 footer {
1107 max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem;
1108 color: var(--text-muted); font-size: .78rem; display: flex; gap: 1rem;
1109 }
1110 footer a { color: var(--text-muted); }
1111</style>
1112</head>
1113<body>
1114<main>
1115 <div class="top">
1116 <div>
1117 <h1>graft — social IDs</h1>
1118 <span class="sub">ActivityPub + AT Proto</span>
1119 </div>
1120 <a class="nav-link" href="/">← Dashboard</a>
1121 </div>
1122
1123 <div class="card">
1124 {{range .Series}}
1125 <div class="series-block">
1126 <div class="handle">@{{.Handle}}</div>
1127 <h2>ActivityPub · {{.FollowerCount}} follower{{if ne .FollowerCount 1}}s{{end}}</h2>
1128 <div class="endpoints">
1129 <a href="{{.WebfingerURL}}" target="_blank" rel="noopener">WebFinger</a>
1130 <a href="{{.ActorURL}}" target="_blank" rel="noopener">Actor</a>
1131 <a href="{{.OutboxURL}}" target="_blank" rel="noopener">Outbox</a>
1132 </div>
1133 <h2>AT Proto / Bluesky</h2>
1134 <span class="badge">{{if .BlueskyConfigured}}posts on new activity{{else}}not configured{{end}}</span>
1135 </div>
1136 {{else}}
1137 <span class="empty">no series configured</span>
1138 {{end}}
1139 </div>
1140</main>
1141<footer>
1142 {{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener">{{.SourceURL}}</a>{{end}}
1143 <span>GPLv3</span>
1144</footer>
1145</body>
1146</html>
1147`))