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 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 &lt;-&gt; Radicle sync — last 3 days</span> 695 </div> 696 {{if .SocialEnabled}}<a class="nav-link" href="/social">Social bridges &rarr;</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="/">&larr; 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 &middot; {{.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`))