Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

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 &lt;-&gt; 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`))