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