Federated via graft
12 kB
431 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 "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 <-> 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`))