Federated via graft
10 kB
372 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 "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 <-> 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`))