diff options
| -rw-r--r-- | internal/daemon/daemon.go | 82 | ||||
| -rw-r--r-- | internal/daemon/daemon_test.go | 81 | ||||
| -rw-r--r-- | internal/version/version.go | 2 |
3 files changed, 164 insertions, 1 deletions
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 852051a..63d80d4 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path/filepath" + "sync" "time" "codeberg.org/snonux/goprecords/internal/authkeys" @@ -22,6 +23,7 @@ const ( defaultReadTimeout = 2 * time.Minute defaultWriteTimeout = 2 * time.Minute defaultIdleTimeout = 2 * time.Minute + rootReportCacheTTL = 10 * time.Minute ) // Config holds settings for the HTTP report/upload daemon. @@ -44,6 +46,7 @@ func NewHandler(statsDir string) (http.Handler, error) { func routes(statsDir, authDB string, store *authkeys.Store) http.Handler { mux := http.NewServeMux() + mux.HandleFunc("/", root(statsDir)) mux.HandleFunc("/health", health) mux.HandleFunc("/livez", health) mux.HandleFunc("/readyz", readiness(statsDir, authDB)) @@ -52,6 +55,31 @@ func routes(statsDir, authDB string, store *authkeys.Store) http.Handler { return mux } +type htmlCache struct { + mu sync.Mutex + body []byte + expires time.Time +} + +func (c *htmlCache) get(now time.Time) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if c.body == nil || now.After(c.expires) { + return nil, false + } + cp := make([]byte, len(c.body)) + copy(cp, c.body) + return cp, true +} + +func (c *htmlCache) set(body []byte, now time.Time, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.body = make([]byte, len(body)) + copy(c.body, body) + c.expires = now.Add(ttl) +} + func logWriter(cfg Config) io.Writer { if cfg.LogOutput != nil { return cfg.LogOutput @@ -159,6 +187,60 @@ func health(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) } +func root(statsDir string) http.HandlerFunc { + cache := &htmlCache{} + render := func(ctx context.Context) ([]byte, error) { + aggr := goprecords.NewAggregator(statsDir) + aggregates, err := aggr.Aggregate(ctx) + if err != nil { + return nil, err + } + cfg := goprecords.ReportConfig{ + Category: goprecords.CategoryHost, + Metric: goprecords.MetricUptime, + Limit: 20, + OutputFormat: goprecords.FormatHTML, + All: true, + IncludeKernel: true, + } + var buf bytes.Buffer + if err := goprecords.WriteReports(&buf, aggregates, cfg); err != nil { + return nil, err + } + return buf.Bytes(), nil + } + return rootWithCachedRenderer(cache, time.Now, rootReportCacheTTL, render) +} + +func rootWithCachedRenderer(cache *htmlCache, now func() time.Time, ttl time.Duration, render func(context.Context) ([]byte, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + current := now() + if body, ok := cache.get(current); ok { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + return + } + body, err := render(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + cache.set(body, current, ttl) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + } +} + func readiness(statsDir, authDB string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index a3ff0a6..44f9888 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -3,6 +3,7 @@ package daemon import ( "bytes" "context" + "fmt" "io" "log/slog" "net/http" @@ -101,6 +102,86 @@ func TestLivez(t *testing.T) { } } +func TestRootHTML(t *testing.T) { + fixtures := filepath.Join("..", "..", "fixtures") + srv := httptest.NewServer(testHandler(t, fixtures)) + defer srv.Close() + res, err := http.Get(srv.URL + "/") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status %d", res.StatusCode) + } + ct := res.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/html") { + t.Fatalf("content type %q", ct) + } + body, _ := io.ReadAll(res.Body) + if !strings.Contains(string(body), "<!DOCTYPE html>") { + t.Fatalf("expected html body, got %q", string(body)) + } +} + +func TestRootCacheReuseAndExpiry(t *testing.T) { + cache := &htmlCache{} + nowTime := time.Unix(1000, 0) + now := func() time.Time { return nowTime } + renderCalls := 0 + h := rootWithCachedRenderer(cache, now, 10*time.Minute, func(context.Context) ([]byte, error) { + renderCalls++ + return []byte(fmt.Sprintf("<html>%d</html>", renderCalls)), nil + }) + + req := httptest.NewRequest(http.MethodGet, "http://example/", nil) + w := httptest.NewRecorder() + h(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + first := w.Body.String() + if renderCalls != 1 { + t.Fatalf("render calls %d want 1", renderCalls) + } + + w2 := httptest.NewRecorder() + h(w2, req) + if w2.Code != http.StatusOK { + t.Fatalf("status %d", w2.Code) + } + if w2.Body.String() != first { + t.Fatalf("cached body mismatch: %q != %q", w2.Body.String(), first) + } + if renderCalls != 1 { + t.Fatalf("render calls %d want 1", renderCalls) + } + + nowTime = nowTime.Add(11 * time.Minute) + w3 := httptest.NewRecorder() + h(w3, req) + if w3.Code != http.StatusOK { + t.Fatalf("status %d", w3.Code) + } + if renderCalls != 2 { + t.Fatalf("render calls %d want 2", renderCalls) + } +} + +func TestRootNotFoundForUnknownPath(t *testing.T) { + fixtures := filepath.Join("..", "..", "fixtures") + srv := httptest.NewServer(testHandler(t, fixtures)) + defer srv.Close() + res, err := http.Get(srv.URL + "/no-such-route") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", res.StatusCode) + } +} + func TestReadyzOK(t *testing.T) { srv := httptest.NewServer(testHandler(t, t.TempDir())) defer srv.Close() diff --git a/internal/version/version.go b/internal/version/version.go index c2e9b9f..83b3aa0 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,4 +1,4 @@ package version // Tag is the application release version. -const Tag = "0.3.0" +const Tag = "0.3.1" |
