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// Tracker holds the latest result per repo pair, safe for concurrent use, 36// plus a handle on the state store for the activity dashboard. 37type Tracker struct { 38 startedAt time.Time 39 store *state.Store 40 sourceURL string 41 42 mu sync.Mutex 43 pairs map[string]PairResult 44} 45 46func NewTracker(store *state.Store, sourceURL string) *Tracker { 47 return &Tracker{startedAt: time.Now(), store: store, sourceURL: sourceURL, pairs: map[string]PairResult{}} 48} 49 50// Record stores the outcome of a pass for one pair. Pass nil for a scope 51// that succeeded or wasn't enabled. series is the dashboard row this pair's 52// status badge groups under (see config.RepoPair.Series). 53func (t *Tracker) Record(name, series string, gitErr, issuesErr, patchErr error) { 54 r := PairResult{Name: name, Series: series, LastRun: time.Now()} 55 if gitErr != nil { 56 r.GitError = gitErr.Error() 57 } 58 if issuesErr != nil { 59 r.IssuesError = issuesErr.Error() 60 } 61 if patchErr != nil { 62 r.PatchError = patchErr.Error() 63 } 64 t.mu.Lock() 65 defer t.mu.Unlock() 66 t.pairs[name] = r 67} 68 69func (t *Tracker) snapshot() []PairResult { 70 t.mu.Lock() 71 defer t.mu.Unlock() 72 out := make([]PairResult, 0, len(t.pairs)) 73 for _, r := range t.pairs { 74 out = append(out, r) 75 } 76 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) 77 return out 78} 79 80type statusResponse struct { 81 StartedAt time.Time `json:"started_at"` 82 Pairs []PairResult `json:"pairs"` 83} 84 85// Handler serves: 86// 87// GET /healthz - 200 if every pair's last pass was clean, 503 otherwise 88// GET /api/status - the JSON snapshot Record() has been fed 89// GET / - the activity dashboard (calendar + recent commits) 90func (t *Tracker) Handler() http.Handler { 91 mux := http.NewServeMux() 92 93 mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { 94 for _, p := range t.snapshot() { 95 if !p.ok() { 96 w.WriteHeader(http.StatusServiceUnavailable) 97 return 98 } 99 } 100 w.WriteHeader(http.StatusOK) 101 }) 102 103 mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { 104 resp := statusResponse{StartedAt: t.startedAt, Pairs: t.snapshot()} 105 w.Header().Set("Content-Type", "application/json") 106 json.NewEncoder(w).Encode(resp) 107 }) 108 109 mux.HandleFunc("/", t.serveDashboard) 110 111 return mux 112} 113 114const dashboardDays = 3 115 116func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) { 117 since := time.Now().UTC().AddDate(0, 0, -(dashboardDays - 1)).Truncate(24 * time.Hour) 118 entries, err := t.store.ActivitySince(since) 119 if err != nil { 120 http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError) 121 return 122 } 123 124 pairs := groupPairStatus(t.snapshot()) 125 health := make(map[string]bool, len(pairs)) 126 for _, p := range pairs { 127 health[p.Name] = p.OK 128 } 129 130 data := dashboardData{ 131 Series: buildSeriesRows(entries, health), 132 Activity: recentActivity(entries), 133 SourceURL: t.sourceURL, 134 } 135 w.Header().Set("Content-Type", "text/html; charset=utf-8") 136 w.Header().Set("Cache-Control", "no-store") 137 if err := dashboardTmpl.Execute(w, data); err != nil { 138 http.Error(w, err.Error(), http.StatusInternalServerError) 139 } 140} 141 142type dashboardData struct { 143 Series []seriesRow 144 Activity []commitLine 145 SourceURL string 146} 147 148// pairStatus is one badge in the dashboard's top status card: one per 149// series (repo), OK only if every pair mirroring that repo's last pass was 150// clean — grouped so a repo with 3 mirrored sides (e.g. two Forgejos plus 151// an external instance) shows one badge, not three. 152type pairStatus struct { 153 Name string 154 OK bool 155} 156 157func groupPairStatus(results []PairResult) []pairStatus { 158 order := []string{} 159 ok := map[string]bool{} 160 for _, r := range results { 161 name := r.Series 162 if name == "" { 163 name = r.Name 164 } 165 if _, seen := ok[name]; !seen { 166 order = append(order, name) 167 ok[name] = true 168 } 169 if !r.ok() { 170 ok[name] = false 171 } 172 } 173 sort.Strings(order) 174 out := make([]pairStatus, 0, len(order)) 175 for _, name := range order { 176 out = append(out, pairStatus{Name: name, OK: ok[name]}) 177 } 178 return out 179} 180 181// seriesRow is one full-width strip of the activity heatmap: everything 182// mirrored for one underlying repository (grouped across its Forgejo and 183// Radicle sides via config.RepoPair.Series), one cell per actual event — 184// not one per day — so nothing is ever aggregated behind a single tooltip. 185type seriesRow struct { 186 Name string 187 OK bool 188 SubRows []subRow 189} 190 191// subRow is one mirrored side of a series — one real repo (a specific 192// Forgejo instance, or a specific Radicle node) — shown once the series is 193// expanded, with only that side's own events and its own link. A series 194// with 3 mirrored sides (two Forgejos plus Radicle) gets 3 sub-rows, never 195// one link picked arbitrarily to represent all of them. 196type subRow struct { 197 Label string 198 URL string 199 Events []event 200} 201 202// event is one activity_log row: one heatmap cell, one tooltip, one link. 203type event struct { 204 Kind string // "git", "issue", "patch" — drives the cell's color 205 Label string // "Commit", "Issue", "Patch" 206 Text string 207 URL string 208 Server string // which mirrored side this happened on, e.g. "f1", "alice" 209 When string // human-readable timestamp, for the tooltip 210} 211 212type commitLine struct { 213 When string 214 Pair string 215 PairURL string 216 Server string 217 Kind string // "Commit", "Issue", "Patch" 218 Message string 219 URL string 220} 221 222// serverLabel names which side of a series a repo_pair belongs to, e.g. 223// "federation-x-f1" under series "federation-x" becomes "f1". Several pairs 224// share one series row/badge, so without this an operator can't tell which 225// mirrored server (f1, f2, an external instance...) a given event actually 226// happened on. Empty if the pair name doesn't start with its series name. 227func serverLabel(repoPair, series string) string { 228 if series == "" { 229 return "" 230 } 231 suffix := strings.TrimPrefix(repoPair, series+"-") 232 if suffix == repoPair { 233 return "" 234 } 235 return suffix 236} 237 238// hostLabel shortens a URL's host to its canonical name for compact 239// display: the TLD is dropped ("f1.cyberwild.org" -> "f1.cyberwild", 240// "artefacts.bimr.net" -> "artefacts.bimr"), so every server — ours or an 241// external instance's — reads the same way, never a per-pair alias. 242func hostLabel(rawURL string) string { 243 u, err := url.Parse(rawURL) 244 if err != nil || u.Host == "" { 245 return "" 246 } 247 host := u.Host 248 // A Radicle Explorer URL's host is always the shared frontend 249 // (e.g. radicle.cyberwild.org) — the actual node lives in the path, 250 // /nodes/<node-host>/<rid>/... — so different Radicle nodes get 251 // distinct labels instead of collapsing into one "radicle.cyberwild". 252 if segs := strings.Split(strings.TrimPrefix(u.Path, "/"), "/"); len(segs) >= 2 && segs[0] == "nodes" { 253 host = segs[1] 254 } 255 parts := strings.Split(host, ".") 256 if len(parts) > 1 { 257 parts = parts[:len(parts)-1] 258 } 259 return strings.Join(parts, ".") 260} 261 262// buildSeriesRows groups entries by their Series (one row per monitored 263// repo, spanning both its Forgejo and Radicle sides) — Atlassian 264// Statuspage style: one full-width horizontal strip per series. Each entry 265// becomes its own cell, oldest first, so a busy day never hides events 266// behind one shared tooltip. 267func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool) []seriesRow { 268 type subAcc struct { 269 url string 270 events []state.ActivityEntry 271 } 272 type seriesAcc struct { 273 subOrder []string 274 subs map[string]*subAcc 275 } 276 seriesAccs := map[string]*seriesAcc{} 277 var seriesOrder []string 278 for _, e := range entries { 279 name := e.Series 280 if name == "" { 281 name = e.RepoPair 282 } 283 sa, ok := seriesAccs[name] 284 if !ok { 285 sa = &seriesAcc{subs: map[string]*subAcc{}} 286 seriesAccs[name] = sa 287 seriesOrder = append(seriesOrder, name) 288 } 289 server := hostLabel(e.URL) 290 if server == "" { 291 server = serverLabel(e.RepoPair, e.Series) 292 } 293 if server == "" { 294 server = e.RepoPair 295 } 296 sub, ok := sa.subs[server] 297 if !ok { 298 sub = &subAcc{} 299 sa.subs[server] = sub 300 sa.subOrder = append(sa.subOrder, server) 301 } 302 if sub.url == "" { 303 sub.url = repoRootURL(e.URL) 304 } 305 sub.events = append(sub.events, e) 306 } 307 sort.Strings(seriesOrder) 308 309 rows := make([]seriesRow, 0, len(seriesOrder)) 310 for _, name := range seriesOrder { 311 sa := seriesAccs[name] 312 subOrder := append([]string(nil), sa.subOrder...) 313 sort.Strings(subOrder) 314 315 subRows := make([]subRow, 0, len(subOrder)) 316 for _, label := range subOrder { 317 sub := sa.subs[label] 318 // entries arrive newest-first (see ActivitySince); the 319 // heatmap reads left-to-right as oldest-to-newest. 320 es := make([]state.ActivityEntry, len(sub.events)) 321 copy(es, sub.events) 322 sort.Slice(es, func(i, j int) bool { return es[i].OccurredAt.Before(es[j].OccurredAt) }) 323 subRows = append(subRows, subRow{Label: label, URL: sub.url, Events: toEvents(es)}) 324 } 325 326 ok, known := health[name] 327 if !known { 328 ok = true 329 } 330 rows = append(rows, seriesRow{Name: name, OK: ok, SubRows: subRows}) 331 } 332 return rows 333} 334 335func toEvents(es []state.ActivityEntry) []event { 336 out := make([]event, 0, len(es)) 337 for _, e := range es { 338 server := hostLabel(e.URL) 339 if server == "" { 340 server = serverLabel(e.RepoPair, e.Series) 341 } 342 out = append(out, event{ 343 Kind: e.Kind, 344 Label: kindLabel(e.Kind), 345 Text: e.Summary, 346 URL: e.URL, 347 Server: server, 348 When: e.OccurredAt.Format("Mon, Jan 2, 15:04"), 349 }) 350 } 351 return out 352} 353 354func kindLabel(kind string) string { 355 switch kind { 356 case "git": 357 return "Commit" 358 case "issue": 359 return "Issue" 360 case "patch": 361 return "Patch" 362 default: 363 return kind 364 } 365} 366 367// recentActivity lists every mirrored event — commits, issues, and 368// patches/pull requests alike, newest first. graft doesn't mirror stars or 369// other social metadata: its sync scope is git content, issues, and 370// patches/PRs only (see config.SyncScope), so that's what shows up here. 371func recentActivity(entries []state.ActivityEntry) []commitLine { 372 out := make([]commitLine, 0, len(entries)) 373 for _, e := range entries { 374 name := e.Series 375 if name == "" { 376 name = e.RepoPair 377 } 378 server := hostLabel(e.URL) 379 if server == "" { 380 server = serverLabel(e.RepoPair, e.Series) 381 } 382 // The (server) parenthetical should point at that specific 383 // server's own repo, not the series' shared Radicle explorer 384 // link (which is fixed to whichever pair logged first). 385 pairURL := repoRootURL(e.URL) 386 if pairURL == "" { 387 pairURL = e.SeriesURL 388 } 389 out = append(out, commitLine{ 390 When: e.OccurredAt.Format("Jan 2, 15:04"), 391 Pair: name, 392 PairURL: pairURL, 393 Server: server, 394 Kind: kindLabel(e.Kind), 395 Message: e.Summary, 396 URL: e.URL, 397 }) 398 } 399 return out 400} 401 402// repoRootURL trims an event's specific-item URL (a commit, issue, or 403// patch/PR page) back down to its repository root, e.g. 404// ".../owner/repo/commit/<sha>" -> ".../owner/repo". Empty if rawURL is 405// empty or doesn't contain a recognized item path. 406func repoRootURL(rawURL string) string { 407 // Forgejo uses singular "/commit/"; Radicle Explorer uses plural 408 // "/commits/" — both must be recognized, or every Radicle-hosted 409 // commit link falls back to a non-clickable label. 410 for _, marker := range []string{"/commit/", "/commits/", "/issues/", "/pulls/", "/patches/"} { 411 if idx := strings.Index(rawURL, marker); idx >= 0 { 412 return rawURL[:idx] 413 } 414 } 415 return "" 416} 417 418var dashboardTmpl = template.Must(template.New("dashboard").Parse(`<!doctype html> 419<html lang="en"> 420<head> 421<meta charset="utf-8"> 422<meta name="viewport" content="width=device-width, initial-scale=1"> 423<title>graft</title> 424<style> 425 :root { 426 color-scheme: light; 427 --surface: #ffffff; 428 --surface-sunk: #f4f5f7; 429 --text: #172b4d; 430 --text-muted: #6b778c; 431 --border: #dfe1e6; 432 --brand: #0052cc; 433 --brand-dark: #0747a6; 434 --kind-git: #0052cc; 435 --kind-issue: #00875a; 436 --kind-patch: #6554c0; 437 --bad: #de350b; 438 --good: #36b37e; 439 } 440 * { box-sizing: border-box; } 441 body { 442 margin: 0; 443 background: var(--surface-sunk); 444 color: var(--text); 445 font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 446 } 447 main { max-width: 920px; margin: 0 auto; padding: 2.5rem 1.5rem 1rem; } 448 449 .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; } 450 h1 { font-size: 1.25rem; margin: 0; font-weight: 600; } 451 .sub { color: var(--text-muted); font-size: .85rem; } 452 453 .card { 454 background: var(--surface); 455 border: 1px solid var(--border); 456 border-radius: 4px; 457 padding: 1.25rem 1.5rem; 458 margin-bottom: 1.25rem; 459 } 460 461 .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); flex: none; display: inline-block; } 462 .dot.bad { background: var(--bad); } 463 464 .heatmap-head { display: flex; align-items: baseline; justify-content: space-between; } 465 .heatmap-head h2 { margin: 0; } 466 .legend { display: flex; gap: .9rem; margin-bottom: .9rem; } 467 .legend span { display: flex; align-items: center; gap: .35rem; font-size: .74rem; color: var(--text-muted); } 468 .legend .sw { width: 8px; height: 8px; border-radius: 2px; display: inline-block; } 469 .legend .sw[data-kind="git"] { background: var(--kind-git); } 470 .legend .sw[data-kind="issue"] { background: var(--kind-issue); } 471 .legend .sw[data-kind="patch"] { background: var(--kind-patch); } 472 .heatmap { display: flex; flex-direction: column; gap: .15rem; } 473 .series-group { border-bottom: 1px solid var(--border); } 474 .series-group:last-child { border-bottom: none; } 475 .series-group[open] { padding-bottom: .5rem; } 476 .series-label { 477 display: flex; align-items: center; gap: .45rem; padding: .55rem 0; 478 cursor: pointer; list-style: none; 479 } 480 .series-label::-webkit-details-marker { display: none; } 481 .series-label::before { 482 content: ""; width: 0; height: 0; flex: none; 483 border-style: solid; border-width: 4px 0 4px 5px; 484 border-color: transparent transparent transparent var(--text-muted); 485 transition: transform .12s ease; 486 } 487 .series-group[open] > .series-label::before { transform: rotate(90deg); } 488 .series-name { font-size: .82rem; font-weight: 600; color: var(--text); } 489 .series-count { font-size: .74rem; color: var(--text-muted); } 490 .subrows { display: flex; flex-direction: column; gap: .5rem; padding-left: 1.3rem; } 491 .subrow { display: flex; align-items: center; gap: .75rem; } 492 .subrow-name { 493 flex: 0 0 11rem; font-size: .78rem; color: var(--text-muted); text-decoration: none; 494 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 495 } 496 a.subrow-name { color: var(--brand); } 497 a.subrow-name:hover { text-decoration: underline; } 498 .days { display: flex; gap: 3px; flex-wrap: wrap; } 499 .cell { 500 flex: none; width: 10px; height: 22px; border-radius: 3px; 501 background: var(--border); position: relative; cursor: default; 502 } 503 .cell[data-kind="git"] { background: var(--kind-git); } 504 .cell[data-kind="issue"] { background: var(--kind-issue); } 505 .cell[data-kind="patch"] { background: var(--kind-patch); } 506 507 .tip { 508 display: none; position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%); 509 background: var(--text); color: #fff; border-radius: 6px; 510 font-size: .78rem; z-index: 20; padding: .5rem 0; 511 box-shadow: 0 4px 12px rgba(9,30,66,.25); 512 width: max-content; max-width: min(320px, 90vw); 513 } 514 .cell:nth-child(-n+2) .tip { left: 0; transform: none; } 515 .cell:nth-last-child(-n+2) .tip { left: auto; right: 0; transform: none; } 516 .cell:hover .tip, .tip:hover { display: block; } 517 .tip .tip-date { padding: 0 .75rem .35rem; font-weight: 600; border-bottom: 1px solid rgba(255,255,255,.15); margin-bottom: .35rem; } 518 .tip a.tip-row { 519 display: flex; gap: .4em; padding: .3rem .75rem; color: #fff; text-decoration: none; 520 white-space: normal; overflow-wrap: anywhere; line-height: 1.35; 521 } 522 .tip a.tip-row:hover { background: rgba(255,255,255,.12); } 523 .tip .tip-kind { color: #b3bac5; flex: none; } 524 .tip .tip-empty { padding: 0 .75rem; color: #b3bac5; } 525 526 h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .9rem; font-weight: 600; } 527 .commits { border-top: 1px solid var(--border); } 528 .commit { 529 display: flex; align-items: center; gap: .75rem; padding: .55rem 0; border-bottom: 1px solid var(--border); 530 font-size: .82rem; 531 } 532 .commit .when { color: var(--text-muted); flex: none; width: 9em; } 533 .commit .kind { color: var(--text-muted); flex: none; width: 4.5em; font-size: .74rem; text-transform: uppercase; letter-spacing: .03em; } 534 .commit .pair { 535 color: var(--text-muted); flex: 0 1 auto; max-width: 22em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 536 text-decoration: none; 537 } 538 .commit a.pair { color: var(--brand); } 539 .commit a.pair:hover { text-decoration: underline; } 540 .commit .msg { 541 flex: 1 1 auto; min-width: 0; 542 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; 543 color: inherit; text-decoration: none; 544 } 545 .commit a.msg { color: var(--brand); } 546 .commit a.msg:hover { text-decoration: underline; } 547 .empty { color: var(--text-muted); padding: .5rem 0; } 548 549 footer { 550 max-width: 920px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem; 551 color: var(--text-muted); font-size: .78rem; display: flex; gap: 1rem; 552 } 553 footer a { color: var(--text-muted); } 554</style> 555</head> 556<body> 557<main> 558 <div class="top"> 559 <h1>graft</h1> 560 <span class="sub">Forgejo &lt;-&gt; Radicle sync — last 3 days</span> 561 </div> 562 563 <div class="card"> 564 <div class="heatmap-head"> 565 <h2>Activity by repo</h2> 566 <div class="legend"> 567 <span><i class="sw" data-kind="git"></i>Commit</span> 568 <span><i class="sw" data-kind="issue"></i>Issue</span> 569 <span><i class="sw" data-kind="patch"></i>Patch</span> 570 </div> 571 </div> 572 <div class="heatmap"> 573 {{range .Series}} 574 <details class="series-group"> 575 <summary class="series-label"> 576 <span class="dot{{if not .OK}} bad{{end}}"></span> 577 <span class="series-name">{{.Name}}</span> 578 <span class="series-count">{{len .SubRows}} mirrored side{{if ne (len .SubRows) 1}}s{{end}}</span> 579 </summary> 580 <div class="subrows"> 581 {{range .SubRows}} 582 <div class="subrow"> 583 {{if .URL}} 584 <a class="subrow-name" href="{{.URL}}" target="_blank" rel="noopener">{{.Label}}</a> 585 {{else}} 586 <span class="subrow-name">{{.Label}}</span> 587 {{end}} 588 <div class="days"> 589 {{range .Events}} 590 <div class="cell" data-kind="{{.Kind}}"> 591 <div class="tip"> 592 <div class="tip-date">{{.When}}</div> 593 {{if .URL}} 594 <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}</span>{{.Text}}</a> 595 {{else}} 596 <span class="tip-row"><span class="tip-kind">{{.Label}}</span>{{.Text}}</span> 597 {{end}} 598 </div> 599 </div> 600 {{else}} 601 <span class="tip-empty">nothing synced</span> 602 {{end}} 603 </div> 604 </div> 605 {{end}} 606 </div> 607 </details> 608 {{else}} 609 <span class="empty">nothing synced yet</span> 610 {{end}} 611 </div> 612 </div> 613 614 <div class="card"> 615 <h2>Recent activity</h2> 616 <div class="commits"> 617 {{range .Activity}} 618 <div class="commit"> 619 <span class="when">{{.When}}</span> 620 <span class="kind">{{.Kind}}</span> 621 {{if .PairURL}} 622 <a class="pair" href="{{.PairURL}}" target="_blank" rel="noopener">{{.Pair}}{{if .Server}} ({{.Server}}){{end}}</a> 623 {{else}} 624 <span class="pair">{{.Pair}}{{if .Server}} ({{.Server}}){{end}}</span> 625 {{end}} 626 {{if .URL}} 627 <a class="msg" href="{{.URL}}" target="_blank" rel="noopener">{{.Message}}</a> 628 {{else}} 629 <span class="msg">{{.Message}}</span> 630 {{end}} 631 </div> 632 {{else}} 633 <p class="empty">nothing mirrored yet</p> 634 {{end}} 635 </div> 636 </div> 637</main> 638<footer> 639 {{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener">{{.SourceURL}}</a>{{end}} 640 <span>GPLv3</span> 641</footer> 642</body> 643</html> 644`))