summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/daemon/daemon.go82
-rw-r--r--internal/daemon/daemon_test.go81
-rw-r--r--internal/version/version.go2
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"