mirror of
https://github.com/lukaszraczylo/ghtracker.git
synced 2026-10-13 00:48:42 +00:00
ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics. The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary. The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling.
130 lines
3.9 KiB
Go
130 lines
3.9 KiB
Go
// Package web serves the embedded UI, the JSON state, the health probe and metrics.
|
|
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/ghtracker/internal/collector"
|
|
)
|
|
|
|
const (
|
|
minManualRefreshGap = 5 * time.Minute
|
|
indexFile = "index.html"
|
|
assetsPrefix = "/assets/"
|
|
immutableCache = "public, max-age=31536000, immutable"
|
|
contentSecurity = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
|
"connect-src 'self'; img-src 'self' data:; font-src 'self'; form-action 'self'; base-uri 'none'"
|
|
)
|
|
|
|
// Source supplies dashboard state and accepts refresh requests.
|
|
type Source interface {
|
|
View() collector.View
|
|
Stats() collector.Stats
|
|
RequestRefresh() bool
|
|
}
|
|
|
|
type Server struct {
|
|
lastManual time.Time
|
|
now func() time.Time
|
|
src Source
|
|
ui fs.FS
|
|
metrics http.Handler
|
|
log *slog.Logger
|
|
mu sync.Mutex
|
|
manualRefresh bool
|
|
}
|
|
|
|
// New builds the server; ui is the built frontend (index.html at its root).
|
|
func New(src Source, metrics http.Handler, ui fs.FS, manualRefresh bool, log *slog.Logger) *Server {
|
|
return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now}
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /{$}", s.index)
|
|
mux.HandleFunc("GET /assets/", s.asset)
|
|
mux.HandleFunc("GET /api/state", s.state)
|
|
mux.HandleFunc("GET /healthz", s.health)
|
|
mux.Handle("GET /metrics", s.metrics)
|
|
if s.manualRefresh {
|
|
mux.HandleFunc("POST /refresh", s.refresh)
|
|
}
|
|
return securityHeaders(mux)
|
|
}
|
|
|
|
func securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("Content-Security-Policy", contentSecurity)
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) index(w http.ResponseWriter, _ *http.Request) {
|
|
b, err := fs.ReadFile(s.ui, indexFile)
|
|
if err != nil {
|
|
http.Error(w, "UI is not built: run `pnpm build` before `go build`", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
_, _ = w.Write(b)
|
|
}
|
|
|
|
// asset serves hashed build output under /assets/ from the embedded filesystem.
|
|
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
|
name := path.Clean(strings.TrimPrefix(r.URL.Path, "/"))
|
|
if !strings.HasPrefix("/"+name, assetsPrefix) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", immutableCache)
|
|
http.ServeFileFS(w, r, s.ui, name)
|
|
}
|
|
|
|
func (s *Server) state(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if err := json.NewEncoder(w).Encode(buildState(s.src.View(), s.manualRefresh)); err != nil {
|
|
s.log.Error("encode state", "err", err)
|
|
}
|
|
}
|
|
|
|
// health reports 503 until the first refresh finishes, for probes.
|
|
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
|
if !s.src.Stats().Loaded {
|
|
http.Error(w, "loading", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte("ok\n"))
|
|
}
|
|
|
|
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
|
|
// Browsers label cross-site requests; refuse them so another page cannot spend the API budget.
|
|
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
|
|
http.Error(w, "cross-site refresh refused", http.StatusForbidden)
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
tooSoon := s.now().Sub(s.lastManual) < minManualRefreshGap
|
|
if !tooSoon {
|
|
s.lastManual = s.now()
|
|
}
|
|
s.mu.Unlock()
|
|
if tooSoon {
|
|
http.Error(w, "refresh allowed once per "+minManualRefreshGap.String(), http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
s.src.RequestRefresh()
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|