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