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 "sort" 12 "sync" 13 "time" 14 15 "graft/internal/state" 16) 17 18// PairResult is the outcome of one repo pair's most recent pass. An empty 19// error string means that scope is either disabled or succeeded. 20type PairResult struct { 21 Name string `json:"name"` 22 LastRun time.Time `json:"last_run"` 23 GitError string `json:"git_error,omitempty"` 24 IssuesError string `json:"issues_error,omitempty"` 25 PatchError string `json:"patch_error,omitempty"` 26} 27 28func (r PairResult) ok() bool { 29 return r.GitError == "" && r.IssuesError == "" && r.PatchError == "" 30} 31 32// Tracker holds the latest result per repo pair, safe for concurrent use, 33// plus a handle on the state store for the activity dashboard. 34type Tracker struct { 35 startedAt time.Time 36 store *state.Store 37 sourceURL string 38 39 mu sync.Mutex 40 pairs map[string]PairResult 41} 42 43func NewTracker(store *state.Store, sourceURL string) *Tracker { 44 return &Tracker{startedAt: time.Now(), store: store, sourceURL: sourceURL, pairs: map[string]PairResult{}} 45} 46 47// Record stores the outcome of a pass for one pair. Pass nil for a scope 48// that succeeded or wasn't enabled. 49func (t *Tracker) Record(name string, gitErr, issuesErr, patchErr error) { 50 r := PairResult{Name: name, LastRun: time.Now()} 51 if gitErr != nil { 52 r.GitError = gitErr.Error() 53 } 54 if issuesErr != nil { 55 r.IssuesError = issuesErr.Error() 56 } 57 if patchErr != nil { 58 r.PatchError = patchErr.Error() 59 } 60 t.mu.Lock() 61 defer t.mu.Unlock() 62 t.pairs[name] = r 63} 64 65func (t *Tracker) snapshot() []PairResult { 66 t.mu.Lock() 67 defer t.mu.Unlock() 68 out := make([]PairResult, 0, len(t.pairs)) 69 for _, r := range t.pairs { 70 out = append(out, r) 71 } 72 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) 73 return out 74} 75 76type statusResponse struct { 77 StartedAt time.Time `json:"started_at"` 78 Pairs []PairResult `json:"pairs"` 79} 80 81// Handler serves: 82// 83// GET /healthz - 200 if every pair's last pass was clean, 503 otherwise 84// GET /api/status - the JSON snapshot Record() has been fed 85// GET / - the activity dashboard (calendar + recent commits) 86func (t *Tracker) Handler() http.Handler { 87 mux := http.NewServeMux() 88 89 mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { 90 for _, p := range t.snapshot() { 91 if !p.ok() { 92 w.WriteHeader(http.StatusServiceUnavailable) 93 return 94 } 95 } 96 w.WriteHeader(http.StatusOK) 97 }) 98 99 mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { 100 resp := statusResponse{StartedAt: t.startedAt, Pairs: t.snapshot()} 101 w.Header().Set("Content-Type", "application/json") 102 json.NewEncoder(w).Encode(resp) 103 }) 104 105 mux.HandleFunc("/", t.serveDashboard) 106 107 return mux 108} 109 110const dashboardDays = 90 111 112func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) { 113 since := time.Now().UTC().AddDate(0, 0, -dashboardDays).Truncate(24 * time.Hour) 114 entries, err := t.store.ActivitySince(since) 115 if err != nil { 116 http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError) 117 return 118 } 119 120 data := dashboardData{ 121 Pairs: t.snapshot(), 122 Weeks: buildCalendar(entries, since), 123 Commits: recentCommits(entries), 124 SourceURL: t.sourceURL, 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 SourceURL string 137} 138 139type day struct { 140 Date string // YYYY-MM-DD, human-readable 141 Count int 142 Level int // 0-4, for the color ramp 143 Events []event 144 InRange bool 145} 146 147// event is one activity_log row, shaped for the tooltip's clickable list. 148type event struct { 149 Label string // "3 commits", "1 issue", etc. joined for the summary line 150 Text string 151 URL string 152} 153 154type commitLine struct { 155 When string 156 Pair string 157 Message string 158 URL string 159} 160 161// buildCalendar buckets entries by day and lays them out GitHub-style: 162// one column per week, Sunday to Saturday down each column. 163func buildCalendar(entries []state.ActivityEntry, since time.Time) [][]day { 164 byDay := map[string][]state.ActivityEntry{} 165 for _, e := range entries { 166 key := e.OccurredAt.Format("2006-01-02") 167 byDay[key] = append(byDay[key], e) 168 } 169 170 start := since 171 for start.Weekday() != time.Sunday { 172 start = start.AddDate(0, 0, -1) 173 } 174 today := time.Now().UTC().Truncate(24 * time.Hour) 175 176 var weeks [][]day 177 var week []day 178 for d := start; !d.After(today); d = d.AddDate(0, 0, 1) { 179 key := d.Format("2006-01-02") 180 es := byDay[key] 181 week = append(week, day{ 182 Date: d.Format("Jan 2, 2006"), 183 Count: len(es), 184 Level: levelFor(len(es)), 185 Events: toEvents(es), 186 InRange: !d.Before(since), 187 }) 188 if d.Weekday() == time.Saturday { 189 weeks = append(weeks, week) 190 week = nil 191 } 192 } 193 if len(week) > 0 { 194 weeks = append(weeks, week) 195 } 196 return weeks 197} 198 199func levelFor(count int) int { 200 switch { 201 case count == 0: 202 return 0 203 case count <= 2: 204 return 1 205 case count <= 5: 206 return 2 207 case count <= 10: 208 return 3 209 default: 210 return 4 211 } 212} 213 214func toEvents(es []state.ActivityEntry) []event { 215 out := make([]event, 0, len(es)) 216 for _, e := range es { 217 out = append(out, event{ 218 Label: kindLabel(e.Kind), 219 Text: e.Summary, 220 URL: e.URL, 221 }) 222 } 223 return out 224} 225 226func kindLabel(kind string) string { 227 switch kind { 228 case "git": 229 return "Commit" 230 case "issue": 231 return "Issue" 232 case "patch": 233 return "Patch" 234 default: 235 return kind 236 } 237} 238 239func recentCommits(entries []state.ActivityEntry) []commitLine { 240 var out []commitLine 241 for _, e := range entries { 242 if e.Kind != "git" { 243 continue 244 } 245 out = append(out, commitLine{ 246 When: e.OccurredAt.Format("Jan 2, 15:04"), 247 Pair: e.RepoPair, 248 Message: e.Summary, 249 URL: e.URL, 250 }) 251 } 252 return out 253} 254 255var dashboardTmpl = template.Must(template.New("dashboard").Parse(`<!doctype html> 256<html lang="en"> 257<head> 258<meta charset="utf-8"> 259<meta name="viewport" content="width=device-width, initial-scale=1"> 260<title>graft</title> 261<style> 262 :root { 263 color-scheme: light; 264 --surface: #ffffff; 265 --surface-sunk: #f4f5f7; 266 --text: #172b4d; 267 --text-muted: #6b778c; 268 --border: #dfe1e6; 269 --brand: #0052cc; 270 --brand-dark: #0747a6; 271 --cell-0: #f4f5f7; 272 --cell-1: #cde2fb; 273 --cell-2: #86b6ef; 274 --cell-3: #3987e5; 275 --cell-4: #184f95; 276 --bad: #de350b; 277 --good: #36b37e; 278 } 279 * { box-sizing: border-box; } 280 body { 281 margin: 0; 282 background: var(--surface-sunk); 283 color: var(--text); 284 font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 285 } 286 main { max-width: 920px; margin: 0 auto; padding: 2.5rem 1.5rem 1rem; } 287 288 .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; } 289 h1 { font-size: 1.25rem; margin: 0; font-weight: 600; } 290 .sub { color: var(--text-muted); font-size: .85rem; } 291 292 .card { 293 background: var(--surface); 294 border: 1px solid var(--border); 295 border-radius: 4px; 296 padding: 1.25rem 1.5rem; 297 margin-bottom: 1.25rem; 298 } 299 300 .pairs { display: flex; flex-wrap: wrap; gap: .5rem; } 301 .pair { 302 border: 1px solid var(--border); border-radius: 3px; background: var(--surface-sunk); 303 padding: .35rem .65rem; font-size: .78rem; display: flex; align-items: center; gap: .4rem; 304 } 305 .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--good); flex: none; } 306 .dot.bad { background: var(--bad); } 307 308 .calendar-scroll { overflow-x: auto; overflow-y: visible; padding: 20px 0 4px; } 309 .calendar { display: flex; gap: 3px; width: max-content; } 310 .week { display: flex; flex-direction: column; gap: 3px; } 311 .cell { 312 width: 12px; height: 12px; border-radius: 2px; 313 background: var(--cell-0); position: relative; cursor: default; 314 } 315 .cell[data-level="1"] { background: var(--cell-1); } 316 .cell[data-level="2"] { background: var(--cell-2); } 317 .cell[data-level="3"] { background: var(--cell-3); } 318 .cell[data-level="4"] { background: var(--cell-4); } 319 .cell[data-out-of-range="1"] { visibility: hidden; } 320 321 .tip { 322 display: none; position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); 323 background: var(--text); color: #fff; border-radius: 6px; 324 font-size: .78rem; white-space: nowrap; z-index: 10; padding: .5rem 0; 325 box-shadow: 0 4px 12px rgba(9,30,66,.25); 326 } 327 .cell:hover .tip, .tip:hover { display: block; } 328 .tip .tip-date { padding: 0 .75rem .35rem; font-weight: 600; border-bottom: 1px solid rgba(255,255,255,.15); margin-bottom: .35rem; } 329 .tip a.tip-row { 330 display: block; padding: .3rem .75rem; color: #fff; text-decoration: none; white-space: nowrap; 331 } 332 .tip a.tip-row:hover { background: rgba(255,255,255,.12); } 333 .tip .tip-kind { color: #b3bac5; margin-right: .4em; } 334 .tip .tip-empty { padding: 0 .75rem; color: #b3bac5; } 335 336 h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 .9rem; font-weight: 600; } 337 .commits { border-top: 1px solid var(--border); } 338 a.commit { 339 display: flex; gap: .75rem; padding: .55rem 0; border-bottom: 1px solid var(--border); 340 font-size: .82rem; color: inherit; text-decoration: none; 341 } 342 a.commit:hover .msg { color: var(--brand); text-decoration: underline; } 343 .commit .when { color: var(--text-muted); flex: none; width: 9em; } 344 .commit .pair { color: var(--text-muted); flex: none; width: 10em; } 345 .commit .msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; } 346 .empty { color: var(--text-muted); padding: .5rem 0; } 347 348 footer { 349 max-width: 920px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem; 350 color: var(--text-muted); font-size: .78rem; display: flex; gap: 1rem; 351 } 352 footer a { color: var(--text-muted); } 353</style> 354</head> 355<body> 356<main> 357 <div class="top"> 358 <h1>graft</h1> 359 <span class="sub">Forgejo &lt;-&gt; Radicle sync — last 90 days</span> 360 </div> 361 362 <div class="card"> 363 <div class="pairs"> 364 {{range .Pairs}} 365 <div class="pair"> 366 <span class="dot{{if or .GitError .IssuesError .PatchError}} bad{{end}}"></span> 367 {{.Name}} 368 </div> 369 {{else}} 370 <span class="empty">no pairs configured</span> 371 {{end}} 372 </div> 373 </div> 374 375 <div class="card"> 376 <div class="calendar-scroll"> 377 <div class="calendar"> 378 {{range .Weeks}} 379 <div class="week"> 380 {{range .}} 381 <div class="cell" data-level="{{.Level}}" data-out-of-range="{{if not .InRange}}1{{end}}"> 382 <div class="tip"> 383 <div class="tip-date">{{.Date}}</div> 384 {{range .Events}} 385 {{if .URL}} 386 <a class="tip-row" href="{{.URL}}" target="_blank" rel="noopener"><span class="tip-kind">{{.Label}}</span>{{.Text}}</a> 387 {{else}} 388 <span class="tip-row"><span class="tip-kind">{{.Label}}</span>{{.Text}}</span> 389 {{end}} 390 {{else}} 391 <span class="tip-empty">nothing synced</span> 392 {{end}} 393 </div> 394 </div> 395 {{end}} 396 </div> 397 {{end}} 398 </div> 399 </div> 400 </div> 401 402 <div class="card"> 403 <h2>Recent commits</h2> 404 <div class="commits"> 405 {{range .Commits}} 406 {{if .URL}} 407 <a class="commit" href="{{.URL}}" target="_blank" rel="noopener"> 408 <span class="when">{{.When}}</span> 409 <span class="pair">{{.Pair}}</span> 410 <span class="msg">{{.Message}}</span> 411 </a> 412 {{else}} 413 <div class="commit"> 414 <span class="when">{{.When}}</span> 415 <span class="pair">{{.Pair}}</span> 416 <span class="msg">{{.Message}}</span> 417 </div> 418 {{end}} 419 {{else}} 420 <p class="empty">nothing mirrored yet</p> 421 {{end}} 422 </div> 423 </div> 424</main> 425<footer> 426 {{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener">{{.SourceURL}}</a>{{end}} 427 <span>GPLv3</span> 428</footer> 429</body> 430</html> 431`))