Files
ghtracker/internal/web/server.go
T
lukaszraczyloandGitHub 8cbc495dc7 feat: refresh a single repository from its own button (#2)
* feat(refresh): refresh a single repository with POST /refresh?repo=

The endpoint refreshes one configured repository and returns the full state plus the repo name.
Each repository has a 60-second cooldown that is separate from the full-refresh limit.
A running full refresh or a refresh of the same repository returns 409.
An unknown repository returns 404. Cross-site and disabled-config behaviour stay the same.
The collector reuses its fetch path and replaces only that repository.

* feat(ui): add a refresh button beside each repository

The button appears in the project list and in each attention group header.
It stays hidden when the server disables manual refresh.
The button shows a spinner while its repository refreshes and reports refusals in the notice line.
The store swaps in the returned state, so no extra fetch follows.
2026-10-02 23:16:22 +01:00

198 lines
6.3 KiB
Go

// Package web serves the embedded UI, the JSON state, the health probe and metrics.
package web
import (
"context"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/lukaszraczylo/ghtracker/internal/collector"
)
const (
minManualRefreshGap = 5 * time.Minute
// repoRefreshCooldown limits each repository separately from the full-refresh gap.
repoRefreshCooldown = 60 * time.Second
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
RefreshRepo(ctx context.Context, name string) (collector.View, error)
}
type Server struct {
lastManual time.Time
lastRepo map[string]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,
lastRepo: make(map[string]time.Time)}
}
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
}
if repo := r.URL.Query().Get("repo"); repo != "" {
s.refreshRepo(w, r, repo)
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)
}
// repoRefreshResponse is the full state after the refresh, so the UI can swap it in, plus the repo name.
type repoRefreshResponse struct {
Repo string `json:"repo"`
apiState
}
// refreshRepo refreshes one repository and answers when it finishes. It has its own cooldown and
// never touches the full-refresh gap. A running full refresh or same-repo refresh gives 409.
func (s *Server) refreshRepo(w http.ResponseWriter, r *http.Request, repo string) {
key := strings.ToLower(repo)
s.mu.Lock()
prev, had := s.lastRepo[key]
wait := repoRefreshCooldown - s.now().Sub(prev)
if had && wait > 0 {
s.mu.Unlock()
w.Header().Set("Retry-After", strconv.Itoa(int((wait+time.Second-1)/time.Second)))
writeJSONError(w, http.StatusTooManyRequests, "repository refresh allowed once per "+repoRefreshCooldown.String())
return
}
s.lastRepo[key] = s.now()
s.mu.Unlock()
v, err := s.src.RefreshRepo(r.Context(), repo)
if err != nil {
// A refused or cancelled attempt spent no GitHub budget, so it must not start a cooldown.
s.mu.Lock()
if had {
s.lastRepo[key] = prev
} else {
delete(s.lastRepo, key)
}
s.mu.Unlock()
switch {
case errors.Is(err, collector.ErrUnknownRepo):
writeJSONError(w, http.StatusNotFound, "unknown repository")
case errors.Is(err, collector.ErrRefreshBusy):
writeJSONError(w, http.StatusConflict, "a refresh is already running; try again shortly")
default:
s.log.Error("refresh repo", "repo", repo, "err", err)
writeJSONError(w, http.StatusInternalServerError, "refresh failed")
}
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(repoRefreshResponse{Repo: repo, apiState: buildState(v, s.manualRefresh)}); err != nil {
s.log.Error("encode repo refresh", "err", err)
}
}
func writeJSONError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}