graft
Forgejo <-> Radicle sync — last 90 daysRecent commits
nothing mirrored yet
{{end}}// Package status tracks the outcome of each repo pair's most recent sync // pass, keeps a small dashboard over the activity log, and serves both over // HTTP — so an operator can tell the daemon is alive and see what it's // actually done without reading logs. package status import ( "encoding/json" "html/template" "net/http" "sort" "sync" "time" "graft/internal/state" ) // PairResult is the outcome of one repo pair's most recent pass. An empty // error string means that scope is either disabled or succeeded. type PairResult struct { Name string `json:"name"` LastRun time.Time `json:"last_run"` GitError string `json:"git_error,omitempty"` IssuesError string `json:"issues_error,omitempty"` PatchError string `json:"patch_error,omitempty"` } func (r PairResult) ok() bool { return r.GitError == "" && r.IssuesError == "" && r.PatchError == "" } // Tracker holds the latest result per repo pair, safe for concurrent use, // plus a handle on the state store for the activity dashboard. type Tracker struct { startedAt time.Time store *state.Store sourceURL string mu sync.Mutex pairs map[string]PairResult } func NewTracker(store *state.Store, sourceURL string) *Tracker { return &Tracker{startedAt: time.Now(), store: store, sourceURL: sourceURL, pairs: map[string]PairResult{}} } // Record stores the outcome of a pass for one pair. Pass nil for a scope // that succeeded or wasn't enabled. func (t *Tracker) Record(name string, gitErr, issuesErr, patchErr error) { r := PairResult{Name: name, LastRun: time.Now()} if gitErr != nil { r.GitError = gitErr.Error() } if issuesErr != nil { r.IssuesError = issuesErr.Error() } if patchErr != nil { r.PatchError = patchErr.Error() } t.mu.Lock() defer t.mu.Unlock() t.pairs[name] = r } func (t *Tracker) snapshot() []PairResult { t.mu.Lock() defer t.mu.Unlock() out := make([]PairResult, 0, len(t.pairs)) for _, r := range t.pairs { out = append(out, r) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } type statusResponse struct { StartedAt time.Time `json:"started_at"` Pairs []PairResult `json:"pairs"` } // Handler serves: // // GET /healthz - 200 if every pair's last pass was clean, 503 otherwise // GET /api/status - the JSON snapshot Record() has been fed // GET / - the activity dashboard (calendar + recent commits) func (t *Tracker) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { for _, p := range t.snapshot() { if !p.ok() { w.WriteHeader(http.StatusServiceUnavailable) return } } w.WriteHeader(http.StatusOK) }) mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { resp := statusResponse{StartedAt: t.startedAt, Pairs: t.snapshot()} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) }) mux.HandleFunc("/", t.serveDashboard) return mux } const dashboardDays = 90 func (t *Tracker) serveDashboard(w http.ResponseWriter, r *http.Request) { since := time.Now().UTC().AddDate(0, 0, -dashboardDays).Truncate(24 * time.Hour) entries, err := t.store.ActivitySince(since) if err != nil { http.Error(w, "load activity: "+err.Error(), http.StatusInternalServerError) return } data := dashboardData{ Pairs: t.snapshot(), Weeks: buildCalendar(entries, since), Commits: recentCommits(entries), SourceURL: t.sourceURL, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := dashboardTmpl.Execute(w, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } type dashboardData struct { Pairs []PairResult Weeks [][]day Commits []commitLine SourceURL string } type day struct { Date string // YYYY-MM-DD, human-readable Count int Level int // 0-4, for the color ramp Events []event InRange bool } // event is one activity_log row, shaped for the tooltip's clickable list. type event struct { Label string // "3 commits", "1 issue", etc. joined for the summary line Text string URL string } type commitLine struct { When string Pair string Message string URL string } // buildCalendar buckets entries by day and lays them out GitHub-style: // one column per week, Sunday to Saturday down each column. func buildCalendar(entries []state.ActivityEntry, since time.Time) [][]day { byDay := map[string][]state.ActivityEntry{} for _, e := range entries { key := e.OccurredAt.Format("2006-01-02") byDay[key] = append(byDay[key], e) } start := since for start.Weekday() != time.Sunday { start = start.AddDate(0, 0, -1) } today := time.Now().UTC().Truncate(24 * time.Hour) var weeks [][]day var week []day for d := start; !d.After(today); d = d.AddDate(0, 0, 1) { key := d.Format("2006-01-02") es := byDay[key] week = append(week, day{ Date: d.Format("Jan 2, 2006"), Count: len(es), Level: levelFor(len(es)), Events: toEvents(es), InRange: !d.Before(since), }) if d.Weekday() == time.Saturday { weeks = append(weeks, week) week = nil } } if len(week) > 0 { weeks = append(weeks, week) } return weeks } func levelFor(count int) int { switch { case count == 0: return 0 case count <= 2: return 1 case count <= 5: return 2 case count <= 10: return 3 default: return 4 } } func toEvents(es []state.ActivityEntry) []event { out := make([]event, 0, len(es)) for _, e := range es { out = append(out, event{ Label: kindLabel(e.Kind), Text: e.Summary, URL: e.URL, }) } return out } func kindLabel(kind string) string { switch kind { case "git": return "Commit" case "issue": return "Issue" case "patch": return "Patch" default: return kind } } func recentCommits(entries []state.ActivityEntry) []commitLine { var out []commitLine for _, e := range entries { if e.Kind != "git" { continue } out = append(out, commitLine{ When: e.OccurredAt.Format("Jan 2, 15:04"), Pair: e.RepoPair, Message: e.Summary, URL: e.URL, }) } return out } var dashboardTmpl = template.Must(template.New("dashboard").Parse(`
nothing mirrored yet
{{end}}