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 "fmt" 10 "html/template" 11 "net/http" 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 LastRun time.Time `json:"last_run"` 25 GitError string `json:"git_error,omitempty"` 26 IssuesError string `json:"issues_error,omitempty"` 27 PatchError string `json:"patch_error,omitempty"` 28} 29 30func (r PairResult) ok() bool { 31 return r.GitError == "" && r.IssuesError == "" && r.PatchError == "" 32} 33 34// Tracker holds the latest result per repo pair, safe for concurrent use, 35// plus a handle on the state store for the activity dashboard. 36type Tracker struct { 37 startedAt time.Time 38 store *state.Store 39 40 mu sync.Mutex 41 pairs map[string]PairResult 42} 43 44func NewTracker(store *state.Store) *Tracker { 45 return &Tracker{startedAt: time.Now(), store: store, pairs: map[string]PairResult{}} 46} 47 48// Record stores the outcome of a pass for one pair. Pass nil for a scope 49// that succeeded or wasn't enabled. 50func (t *Tracker) Record(name string, gitErr, issuesErr, patchErr error) { 51 r := PairResult{Name: name, LastRun: time.Now()} 52 if gitErr != nil { 53 r.GitError = gitErr.Error() 54 } 55 if issuesErr != nil { 56 r.IssuesError = issuesErr.Error() 57 } 58 if patchErr != nil { 59 r.PatchError = patchErr.Error() 60 } 61 t.mu.Lock() 62 defer t.mu.Unlock() 63 t.pairs[name] = r 64} 65 66func (t *Tracker) snapshot() []PairResult { 67 t.mu.Lock() 68 defer t.mu.Unlock() 69 out := make([]PairResult, 0, len(t.pairs)) 70 for _, r := range t.pairs { 71 out = append(out, r) 72 } 73 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) 74 return out 75} 76 77type statusResponse struct { 78 StartedAt time.Time `json:"started_at"` 79 Pairs []PairResult `json:"pairs"` 80} 81 82// Handler serves: 83// 84// GET /healthz - 200 if every pair's last pass was clean, 503 otherwise 85// GET /api/status - the JSON snapshot Record() has been fed 86// GET / - the activity dashboard (calendar + recent commits) 87func (t *Tracker) Handler() http.Handler { 88 mux := http.NewServeMux() 89 90 mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { 91 for _, p := range t.snapshot() { 92 if !p.ok() { 93 w.WriteHeader(http.StatusServiceUnavailable) 94 return 95 } 96 } 97 w.WriteHeader(http.StatusOK) 98 }) 99 100 mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { 101 resp := statusResponse{StartedAt: t.startedAt, Pairs: t.snapshot()} 102 w.Header().Set("Content-Type", "application/json") 103 json.NewEncoder(w).Encode(resp) 104 }) 105 106 mux.HandleFunc("/", t.serveDashboard) 107 108 return mux 109} 110 111const dashboardDays = 90 112 113func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) { 114 since := time.Now().UTC().AddDate(0, 0, -dashboardDays).Truncate(24 * time.Hour) 115 entries, err := t.store.ActivitySince(since) 116 if err != nil { 117 http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError) 118 return 119 } 120 121 data := dashboardData{ 122 Pairs: t.snapshot(), 123 Weeks: buildCalendar(entries, since), 124 Commits: recentCommits(entries), 125 } 126 w.Header().Set("Content-Type", "text/html; charset=utf-8") 127 if err := dashboardTmpl.Execute(w, data); err != nil { 128 http.Error(w, err.Error(), http.StatusInternalServerError) 129 } 130} 131 132type dashboardData struct { 133 Pairs []PairResult 134 Weeks [][]day 135 Commits []commitLine 136} 137 138type day struct { 139 Date string // YYYY-MM-DD 140 Count int 141 Level int // 0-4, for the color ramp 142 Tooltip string 143 InRange bool 144} 145 146type commitLine struct { 147 When string 148 Pair string 149 Message string 150} 151 152// buildCalendar buckets entries by day and lays them out GitHub-style: 153// one column per week, Sunday to Saturday down each column. 154func buildCalendar(entries []state.ActivityEntry, since time.Time) [][]day { 155 byDay := map[string][]state.ActivityEntry{} 156 for _, e := range entries { 157 key := e.OccurredAt.Format("2006-01-02") 158 byDay[key] = append(byDay[key], e) 159 } 160 161 start := since 162 for start.Weekday() != time.Sunday { 163 start = start.AddDate(0, 0, -1) 164 } 165 today := time.Now().UTC().Truncate(24 * time.Hour) 166 167 var weeks [][]day 168 var week []day 169 for d := start; !d.After(today); d = d.AddDate(0, 0, 1) { 170 key := d.Format("2006-01-02") 171 es := byDay[key] 172 week = append(week, day{ 173 Date: key, 174 Count: len(es), 175 Level: levelFor(len(es)), 176 Tooltip: tooltipFor(key, es), 177 InRange: !d.Before(since), 178 }) 179 if d.Weekday() == time.Saturday { 180 weeks = append(weeks, week) 181 week = nil 182 } 183 } 184 if len(week) > 0 { 185 weeks = append(weeks, week) 186 } 187 return weeks 188} 189 190func levelFor(count int) int { 191 switch { 192 case count == 0: 193 return 0 194 case count <= 2: 195 return 1 196 case count <= 5: 197 return 2 198 case count <= 10: 199 return 3 200 default: 201 return 4 202 } 203} 204 205func tooltipFor(date string, es []state.ActivityEntry) string { 206 if len(es) == 0 { 207 return date + ": nothing synced" 208 } 209 byKind := map[string]int{} 210 for _, e := range es { 211 byKind[e.Kind]++ 212 } 213 var parts []string 214 for _, k := range []string{"git", "issue", "patch"} { 215 if n := byKind[k]; n > 0 { 216 parts = append(parts, fmt.Sprintf("%d %s", n, plural(k, n))) 217 } 218 } 219 return fmt.Sprintf("%s: %s", date, strings.Join(parts, ", ")) 220} 221 222func plural(word string, n int) string { 223 if n == 1 { 224 return word 225 } 226 if word == "patch" { 227 return "patches" 228 } 229 return word + "s" 230} 231 232func recentCommits(entries []state.ActivityEntry) []commitLine { 233 var out []commitLine 234 for _, e := range entries { 235 if e.Kind != "git" { 236 continue 237 } 238 out = append(out, commitLine{ 239 When: e.OccurredAt.Format("2006-01-02 15:04"), 240 Pair: e.RepoPair, 241 Message: e.Summary, 242 }) 243 } 244 return out 245} 246 247var dashboardTmpl = template.Must(template.New("dashboard").Parse(`<!doctype html> 248<html lang="en"> 249<head> 250<meta charset="utf-8"> 251<title>graft</title> 252<style> 253 :root { 254 color-scheme: light; 255 --surface: #fcfcfb; 256 --text: #0b0b0b; 257 --text-muted: #52514e; 258 --border: #e4e2dc; 259 --cell-0: #ebedf0; 260 --cell-1: #cde2fb; 261 --cell-2: #86b6ef; 262 --cell-3: #3987e5; 263 --cell-4: #184f95; 264 --bad: #d03b3b; 265 } 266 @media (prefers-color-scheme: dark) { 267 :root { 268 color-scheme: dark; 269 --surface: #1a1a19; 270 --text: #ffffff; 271 --text-muted: #c3c2b7; 272 --border: #33322e; 273 --cell-0: #22252a; 274 --cell-1: #16324d; 275 --cell-2: #1c5cab; 276 --cell-3: #3987e5; 277 --cell-4: #86b6ef; 278 --bad: #e66767; 279 } 280 } 281 * { box-sizing: border-box; } 282 body { 283 margin: 0; 284 padding: 2.5rem 1.5rem; 285 background: var(--surface); 286 color: var(--text); 287 font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 288 } 289 main { max-width: 900px; margin: 0 auto; } 290 h1 { font-size: 1.1rem; margin: 0 0 .25rem; } 291 .sub { color: var(--text-muted); margin: 0 0 2rem; } 292 293 .pairs { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 2rem; } 294 .pair { 295 border: 1px solid var(--border); border-radius: 6px; 296 padding: .4rem .7rem; font-size: .8rem; display: flex; align-items: center; gap: .4rem; 297 } 298 .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cell-3); flex: none; } 299 .dot.bad { background: var(--bad); } 300 301 .calendar { display: flex; gap: 3px; margin-bottom: 2rem; overflow-x: auto; padding-bottom: .5rem; } 302 .week { display: flex; flex-direction: column; gap: 3px; } 303 .cell { 304 width: 11px; height: 11px; border-radius: 2px; 305 background: var(--cell-0); position: relative; cursor: default; 306 } 307 .cell[data-level="1"] { background: var(--cell-1); } 308 .cell[data-level="2"] { background: var(--cell-2); } 309 .cell[data-level="3"] { background: var(--cell-3); } 310 .cell[data-level="4"] { background: var(--cell-4); } 311 .cell[data-out-of-range="1"] { visibility: hidden; } 312 313 .cell .tip { 314 display: none; position: absolute; bottom: 150%; left: 50%; transform: translateX(-50%); 315 background: var(--text); color: var(--surface); padding: .3rem .5rem; border-radius: 4px; 316 font-size: .72rem; white-space: nowrap; z-index: 1; pointer-events: none; 317 } 318 .cell:hover .tip { display: block; } 319 320 h2 { font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .75rem; } 321 .commits { border-top: 1px solid var(--border); } 322 .commit { display: flex; gap: .75rem; padding: .5rem 0; border-bottom: 1px solid var(--border); font-size: .82rem; } 323 .commit .when { color: var(--text-muted); flex: none; width: 11em; } 324 .commit .pair { color: var(--text-muted); flex: none; width: 10em; } 325 .commit .msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 326 .empty { color: var(--text-muted); padding: 1rem 0; } 327</style> 328</head> 329<body> 330<main> 331 <h1>graft</h1> 332 <p class="sub">Forgejo &lt;-&gt; Radicle sync — last {{len .Weeks}} weeks</p> 333 334 <div class="pairs"> 335 {{range .Pairs}} 336 <div class="pair"> 337 <span class="dot{{if or .GitError .IssuesError .PatchError}} bad{{end}}"></span> 338 {{.Name}} 339 </div> 340 {{else}} 341 <span class="empty">no pairs configured</span> 342 {{end}} 343 </div> 344 345 <div class="calendar"> 346 {{range .Weeks}} 347 <div class="week"> 348 {{range .}} 349 <div class="cell" data-level="{{.Level}}" data-out-of-range="{{if not .InRange}}1{{end}}"> 350 <span class="tip">{{.Tooltip}}</span> 351 </div> 352 {{end}} 353 </div> 354 {{end}} 355 </div> 356 357 <h2>Recent commits</h2> 358 <div class="commits"> 359 {{range .Commits}} 360 <div class="commit"> 361 <span class="when">{{.When}}</span> 362 <span class="pair">{{.Pair}}</span> 363 <span class="msg">{{.Message}}</span> 364 </div> 365 {{else}} 366 <p class="empty">nothing mirrored yet</p> 367 {{end}} 368 </div> 369</main> 370</body> 371</html> 372`))