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