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