Federated via graft
0

Configure Feed

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

Show every configured mirror target, not just ones with activity

Previously a series' heatmap only listed sides the activity log happened
to mention, so a Radicle node nothing had been pushed to yet (or a
Forgejo side that only ever received, never originated) was invisible —
looked like only part of the mesh existed. Now every side declared in
config.yaml gets its own row, marked with no events if it hasn't done
anything in the current window yet.

+108 -18
+44
cmd/sync/main.go
··· 7 7 "flag" 8 8 "log/slog" 9 9 "net/http" 10 + "net/url" 10 11 "os" 12 + "strings" 11 13 "time" 12 14 13 15 "graft/internal/config" ··· 53 55 } 54 56 55 57 tracker := status.NewTracker(st, cfg.SourceURL) 58 + tracker.SetTopology(buildTopology(cfg)) 56 59 runAll := func() { 57 60 for _, rs := range syncers { 58 61 gitErr, issuesErr, patchErr := rs.Run(log) ··· 78 81 for range ticker.C { 79 82 runAll() 80 83 } 84 + } 85 + 86 + // buildTopology declares every mirror target configured for each series 87 + // (its Forgejo side and its Radicle side), so the dashboard can show a 88 + // side that's never produced an event yet — not just sides the activity 89 + // log happens to mention. 90 + func buildTopology(cfg *config.Config) map[string][]status.ServerRef { 91 + topology := map[string][]status.ServerRef{} 92 + seen := map[string]bool{} 93 + add := func(series, host, repoURL string) { 94 + if host == "" { 95 + return 96 + } 97 + key := series + "|" + host 98 + if seen[key] { 99 + return 100 + } 101 + seen[key] = true 102 + topology[series] = append(topology[series], status.ServerRef{Label: host, URL: repoURL}) 103 + } 104 + 105 + for _, pair := range cfg.Repos { 106 + series := pair.Series 107 + if series == "" { 108 + series = pair.Name 109 + } 110 + 111 + fHost := "" 112 + if u, err := url.Parse(pair.Forgejo.BaseURL); err == nil { 113 + fHost = u.Host 114 + } 115 + fURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo 116 + add(series, fHost, fURL) 117 + 118 + rHost := "" 119 + if u, err := url.Parse(pair.Radicle.HTTPBaseURL); err == nil { 120 + rHost = u.Host 121 + } 122 + add(series, rHost, sync.RadicleExplorerLink(pair.Radicle)) 123 + } 124 + return topology 81 125 } 82 126 83 127 func lastSlash(s string) int {
+61 -15
internal/status/status.go
··· 32 32 return r.GitError == "" && r.IssuesError == "" && r.PatchError == "" 33 33 } 34 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. 38 + type ServerRef struct { 39 + Label string // host, e.g. "f1.cyberwild.org" 40 + URL string // repo root on that host 41 + } 42 + 35 43 // Tracker holds the latest result per repo pair, safe for concurrent use, 36 44 // plus a handle on the state store for the activity dashboard. 37 45 type Tracker struct { ··· 39 47 store *state.Store 40 48 sourceURL string 41 49 42 - mu sync.Mutex 43 - pairs map[string]PairResult 50 + mu sync.Mutex 51 + pairs map[string]PairResult 52 + topology map[string][]ServerRef 44 53 } 45 54 46 55 func NewTracker(store *state.Store, sourceURL string) *Tracker { 47 56 return &Tracker{startedAt: time.Now(), store: store, sourceURL: sourceURL, pairs: map[string]PairResult{}} 48 57 } 49 58 59 + // SetTopology declares every mirror target configured for each series, so 60 + // the dashboard can show a side that's never produced an event yet (e.g. a 61 + // Radicle node nothing has been pushed to) instead of only sides the 62 + // activity log happens to mention. 63 + func (t *Tracker) SetTopology(topology map[string][]ServerRef) { 64 + t.mu.Lock() 65 + defer t.mu.Unlock() 66 + t.topology = topology 67 + } 68 + 50 69 // Record stores the outcome of a pass for one pair. Pass nil for a scope 51 70 // that succeeded or wasn't enabled. series is the dashboard row this pair's 52 71 // status badge groups under (see config.RepoPair.Series). ··· 127 146 health[p.Name] = p.OK 128 147 } 129 148 149 + t.mu.Lock() 150 + topology := t.topology 151 + t.mu.Unlock() 152 + 130 153 data := dashboardData{ 131 - Series: buildSeriesRows(entries, health), 154 + Series: buildSeriesRows(entries, health, topology), 132 155 Activity: recentActivity(entries), 133 156 SourceURL: t.sourceURL, 134 157 } ··· 260 283 // Statuspage style: one full-width horizontal strip per series. Each entry 261 284 // becomes its own cell, oldest first, so a busy day never hides events 262 285 // behind one shared tooltip. 263 - func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool) []seriesRow { 286 + func buildSeriesRows(entries []state.ActivityEntry, health map[string]bool, topology map[string][]ServerRef) []seriesRow { 264 287 type subAcc struct { 265 288 url string 266 289 events []state.ActivityEntry ··· 271 294 } 272 295 seriesAccs := map[string]*seriesAcc{} 273 296 var seriesOrder []string 274 - for _, e := range entries { 275 - name := e.Series 276 - if name == "" { 277 - name = e.RepoPair 278 - } 297 + 298 + ensureSeries := func(name string) *seriesAcc { 279 299 sa, ok := seriesAccs[name] 280 300 if !ok { 281 301 sa = &seriesAcc{subs: map[string]*subAcc{}} 282 302 seriesAccs[name] = sa 283 303 seriesOrder = append(seriesOrder, name) 284 304 } 305 + return sa 306 + } 307 + ensureSub := func(sa *seriesAcc, label string) *subAcc { 308 + sub, ok := sa.subs[label] 309 + if !ok { 310 + sub = &subAcc{} 311 + sa.subs[label] = sub 312 + sa.subOrder = append(sa.subOrder, label) 313 + } 314 + return sub 315 + } 316 + 317 + // Seed every configured mirror target first, so a side that has never 318 + // produced an event (e.g. a Radicle node nothing has been pushed to 319 + // yet) still gets its own row instead of being invisible. 320 + for name, refs := range topology { 321 + sa := ensureSeries(name) 322 + for _, ref := range refs { 323 + sub := ensureSub(sa, ref.Label) 324 + if sub.url == "" { 325 + sub.url = ref.URL 326 + } 327 + } 328 + } 329 + 330 + for _, e := range entries { 331 + name := e.Series 332 + if name == "" { 333 + name = e.RepoPair 334 + } 335 + sa := ensureSeries(name) 285 336 server := hostLabel(e.URL) 286 337 if server == "" { 287 338 server = serverLabel(e.RepoPair, e.Series) ··· 289 340 if server == "" { 290 341 server = e.RepoPair 291 342 } 292 - sub, ok := sa.subs[server] 293 - if !ok { 294 - sub = &subAcc{} 295 - sa.subs[server] = sub 296 - sa.subOrder = append(sa.subOrder, server) 297 - } 343 + sub := ensureSub(sa, server) 298 344 if sub.url == "" { 299 345 sub.url = repoRootURL(e.URL) 300 346 }
+3 -3
internal/sync/syncer.go
··· 42 42 rs := &RepoSyncer{pair: pair, forgejo: fc, radicle: rc} 43 43 44 44 forgejoWebURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo 45 - radicleWebURL := radicleExplorerLink(pair.Radicle) 45 + radicleWebURL := RadicleExplorerLink(pair.Radicle) 46 46 47 47 series := pair.Series 48 48 if series == "" { ··· 93 93 return rs, nil 94 94 } 95 95 96 - // radicleExplorerLink builds the base URL for "view this on Radicle" links: 96 + // RadicleExplorerLink builds the base URL for "view this on Radicle" links: 97 97 // <explorer>/nodes/<httpd-host>/<rid>. Empty if no explorer_url is 98 98 // configured, so templates can skip the link entirely. 99 - func radicleExplorerLink(rt config.RadicleTarget) string { 99 + func RadicleExplorerLink(rt config.RadicleTarget) string { 100 100 if rt.ExplorerURL == "" { 101 101 return "" 102 102 }