Federated via graft
0

Configure Feed

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

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