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