Skip to content

Commit b71d2a8

Browse files
feat: enhanced dashboard with statistics (#25) (#1075)
* feat(models): add RequestLog model and AutoMigrate registration Adds RequestLog struct to record proxied HTTP requests for the enhanced dashboard statistics feature (issue #25). Includes BeforeCreate hook for UUID generation, compound (host_id, timestamp) indexes, and GDPR-safe pseudonymised client IP hashing. Registers model in AutoMigrate. * feat(services): add StatsIngester for log fan-out and batch DB writes - Add stats_types.go with StatsPushData, HostStat, StatusStat, StatsPushMessage, and BroadcastHub interface (avoids import cycles) - Add StatsIngester: channel buffer=1000, batch flush at 100 entries or 500ms interval via CreateInBatches; atomic dropped-count tracking - Hash client IPs with SHA-256 (first 16 bytes, hex) for GDPR safety - Add LogWatcher.RegisterIngester() + fan-out in broadcast() - TDD: 6 tests covering count-flush, timer-flush, back-pressure, graceful Stop drain, IP hashing determinism, and fan-out wiring * feat(services): add StatsService with aggregation queries and TTL cache Adds StatsService providing GetSummary (30s TTL cache), GetTopHosts, GetStatusDistribution, GetTrafficVolume, and GetCertExpiry with input validation allowlists. Extends stats_types.go with StatsSummary, TrafficBucket, and CertExpiry types. * feat(frontend/api): add stats API client and TypeScript type definitions Add typed API functions and interfaces for all 8 stats endpoints (summary, top-hosts, status-distribution, traffic-volume, cert-expiry, requests, health, and WebSocket hub) with full Vitest test coverage (33 tests). * feat(frontend/hooks): add useStats and useStatsWebSocket hooks Add six TanStack Query hooks (useStatsSummary, useTopHosts, useStatusDistribution, useTrafficVolume, useCertExpiry, useStatsHealth) with stable query keys and appropriate polling intervals. Add useStatsWebSocket hook that tracks live summary updates via the stats WebSocket and disables REST polling when connected. Full Vitest coverage for all hooks (22 tests). Also remove unicorn/no-array-for-each ESLint rule removed in unicorn v66. * feat(frontend/components): add stats chart and widget components Add 8 pure presentational components under frontend/src/components/stats/: - RequestCountWidget: 3-stat card for 24h/7d/30d request counts - TopHostsChart: horizontal bar chart (recharts BarChart) - StatusDistributionChart: donut chart with accessible HTML summary list - TrafficVolumeChart: line chart with KB/MB Y-axis formatting - CertExpiryList: accessible table with red/amber/green day-based color coding - ServiceHealthWidget: WebSocket live/offline indicator + dropped-event warning - PeriodSelector: controlled radio button group for 24h/7d/30d - BucketSelector: controlled radio button group for 1h/6h/1d All components are pure (no data fetching), strictly typed with no `any` types, and keyboard accessible. Includes 58 Vitest unit tests covering loading states, data rendering, color coding, and interaction callbacks. * feat(frontend/dashboard): integrate stats sections into Dashboard page Add a responsive Statistics section to the Dashboard page below the existing content. Uses useStatsWebSocket for live updates, useState for period/bucket controls, and the six stats hooks + eight stats components (RequestCountWidget, ServiceHealthWidget, CertExpiryList, TrafficVolumeChart, TopHostsChart, StatusDistributionChart, PeriodSelector, BucketSelector). Layout is mobile-first with single column on small screens, 2-col on sm/md, 3-col top row on lg. Adds dashboard.statistics and dashboard.trafficVolume i18n keys to all five locale files. Expands Dashboard tests from 3 to 12 cases. * test(e2e): add Playwright tests for enhanced dashboard statistics - tests/stats.spec.ts: 12 E2E tests covering all 9 required scenarios (stats heading, period selector, bucket selector, request count widget, service health widget, cert expiry section, traffic/top-hosts/status distribution chart containers) plus accessibility radio-count assertion - backend/internal/api/handlers/stats_api_integration_test.go: adds TestStatsAPI_CertExpiry_366Days_Returns400 to cover the upper-bound validation (within_days > 365 returns HTTP 400); simplifies function signature to remove unused return value - backend/internal/services/stats_ingester_test.go: adds TestStatsIngester_RegisterHub and TestStatsIngester_ToRequestLog_InvalidTimestamp to cover the RegisterHub wiring path and the timestamp parse-error fallback All 12 E2E tests pass against the running E2E container at :8080. Backend unit tests pass (88.4% coverage, above 87% minimum). Frontend tests pass (87.86% statement coverage, above 85% minimum). GORM scan: 0 CRITICAL/HIGH findings. * docs: update ARCHITECTURE.md and features.md for stats subsystem * feat(api): add stats handler and WebSocket hub for dashboard stats * test: fix patch coverage for stats subsystem Adds targeted tests to cover all previously uncovered patch lines: Backend: - stats_ws_hub_test.go (new): full hub coverage — constructor, non-blocking broadcast, ctx cancel exit, client broadcast, slow-client drop, client unregister, StatsWS upgrade-error path, StatsWS nil-hub close - stats_handler_test.go: error-path 500s for all six handlers, non-integer within_days → 400, invalid limit param silently ignored - stats_ingester_test.go: Stop flushes batches > batchSize; Run drains big batch on ctx cancel (covers batchSize branch in drain loop) - stats_service_test.go: GetTrafficVolume 6h and 1d buckets; GetSummary DB error Frontend: - StatusDistributionChart: extended recharts mock calls Pie label/Tooltip content; adds 1xx test to cover statusClass "other" return - TrafficVolumeChart: mock calls YAxis tickFormatter with MB/KB/B values and Tooltip content to cover formatBytes branches - TopHostsChart: mock calls Tooltip content including hostname ?? label fallback - CertExpiryList: adds undefined-data test to cover (data ?? []) branch - useStatsWebSocket: adds non-stats_update message test for the else branch * feat(frontend): add widget tooltips, top-hosts color coding, and hide/show controls Adds ELI5 info tooltips to all 6 dashboard stats widgets, a color-coded legend for the Top Hosts chart, and a per-widget visibility toggle persisted in localStorage so users can hide widgets they don't need. * fix(frontend): add aria-expanded to sidebar accordion buttons Adding the Dashboard "Customize" button (which also carries aria-expanded) shifted DOM order and caused the WebKit navigation E2E test to target it instead of the sidebar, since the sidebar's collapsible accordion buttons never actually exposed aria-expanded. Add the missing attribute to the real sidebar toggles and scope the test to the sidebar so it tests what it claims to. * fix(api): join proxy_hosts to populate Top Hosts hostname GetTopHosts only selected host_id and a count, never the hostname, so every entry in the Top Hosts legend and tooltip rendered with a blank or duplicate label. With every chart category collapsing to the same empty string, Recharts merged the bars and hovering any bar showed the same (highest) data point. Join proxy_hosts by UUID to populate the real hostname, falling back to host_id if the host has since been deleted. * fix(database): run SQLite integrity check in background to avoid blocking startup PRAGMA quick_check was running synchronously in Connect() and could take well over a minute on larger databases (observed 93.5s), despite being intended as a non-blocking, warn-only check. * fix(database): give startup integrity check its own connection The previous fix moved PRAGMA quick_check to a goroutine, but the main pool is capped at one connection (SQLite single-writer constraint), so the background check still held that connection for the full scan and blocked AutoMigrate behind it. Open a dedicated connection for the check so it no longer serializes against the rest of startup. * fix(api): use proxy host Name instead of domain names for Top Hosts GetTopHosts joined proxy_hosts.domain_names, showing raw domains in the legend/tooltip instead of the user-assigned host Name. Switch the join to proxy_hosts.name to match what users configured. * fix(database): silence record-not-found noise in GORM query logs Optional lookups (e.g. caddy.keepalive_idle/keepalive_count settings) return ErrRecordNotFound when unset, which call sites already handle via `if err == nil` fallbacks. GORM's default logger still logged these as errors on every startup. Configure IgnoreRecordNotFoundError so only real query errors and slow queries are logged. * fix(api): match Top Hosts by domain instead of ProxyHost UUID RequestLog.HostID stores the raw Host header seen by the proxy (a domain), not the ProxyHost UUID, so the previous join on proxy_hosts.uuid = request_logs.host_id never matched and silently fell back to showing the domain. Build a domain -> name lookup from proxy_hosts.domain_names (which can hold several comma-separated domains per host) and resolve hostnames against that instead. * feat(stats): add warmup guide and improve empty state UX - Log successful LogWatcher startup with path info - Add deployment/warmup guide for stats feature (docs) - Improve TrafficVolumeChart empty state with helpful messaging - Add data collection info to tooltip when no data available * test(TrafficVolumeChart): update empty state text assertions * fix(stats): replace CSS variable with hex literal in TrafficVolumeChart SVG presentation attributes cannot resolve CSS custom properties defined as space-separated RGB triplets (Tailwind v4 token format). Replace `var(--color-brand-500)` with `LINE_COLOR = '#3b82f6'` so Recharts renders a visible line stroke. Add unit tests asserting stroke is a valid hex value and tooltip renders correctly. Add Playwright step verifying recharts-line-curve path exists when data is present. * fix(stats): add LineChart mock and update QA report for feature/stats * test: fix recharts mock to drop importActual for Vitest ESM compat * chore(deps): bump go-toml to v2.4.0 and prometheus/common to v0.69.0 --------- Co-authored-by: GitHub Actions <actions@github.com>
1 parent 70af480 commit b71d2a8

58 files changed

Lines changed: 7049 additions & 204 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ARCHITECTURE.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,40 @@ graph TB
329329

330330
**Design Pattern:** Services contain business logic and call multiple repositories/managers
331331

332+
#### Stats Subsystem (`internal/services/stats_*`, `internal/api/handlers/stats_*`)
333+
334+
The stats subsystem collects, aggregates, and broadcasts request metrics for the Dashboard Statistics feature.
335+
336+
**Components:**
337+
338+
- **`RequestLog` model** (`internal/models/request_log.go`): GORM model persisted to the `request_logs` SQLite table. Fields: `HostID`, `Timestamp`, `Method`, `StatusCode`, `BytesSent`, `DurationMs`, `ClientIPHash`. Client IPs are stored as the first 16 bytes of a SHA-256 hash (GDPR-compliant; not reversible).
339+
340+
- **`StatsIngester`** (`internal/services/stats_ingester.go`): Taps the existing `LogWatcher` fan-out channel. Buffers incoming entries and flushes to SQLite in batches (every 500 ms or when 100 entries accumulate). The ingester channel is non-blocking; if the buffer is full, entries are dropped and tracked via `dropped_count` (visible at `GET /api/stats/health`).
341+
342+
- **`StatsService`** (`internal/services/stats_service.go`): Runs aggregation queries against `request_logs` for summary counts, top hosts, status distribution, traffic volume, and request volume. All query results are cached with a 30-second TTL to limit read pressure on SQLite.
343+
344+
- **`StatsWSHub`** (`internal/api/handlers/stats_ws_hub.go`): Implements the `BroadcastHub` interface. Maintains a registry of active WebSocket connections and broadcasts a `StatsPushMessage` to all subscribers whenever the ingester commits a new batch. Clients receive a push signal and re-fetch aggregated data via REST.
345+
346+
**API Endpoints** (all require JWT authentication, mounted under `/api/stats/`):
347+
348+
| Method | Path | Description |
349+
|--------|------|-------------|
350+
| `GET` | `/summary` | 24 h / 7 d / 30 d request counts |
351+
| `GET` | `/top-hosts` | Top hosts by request count (`period`, `limit`) |
352+
| `GET` | `/status-distribution` | HTTP status code breakdown (`period`) |
353+
| `GET` | `/traffic-volume` | Bytes sent over time (`bucket`) |
354+
| `GET` | `/cert-expiry` | Upcoming SSL cert expirations (`within_days`) |
355+
| `GET` | `/requests` | Request volume over time (`bucket`) |
356+
| `GET` | `/health` | Ingester health including `dropped_count` |
357+
| `WS` | `/ws` | Real-time stats push (upgrade) |
358+
359+
**Frontend:**
360+
361+
- `frontend/src/api/stats.ts` — typed API client and WebSocket helper
362+
- `frontend/src/hooks/useStats.ts` — 6 TanStack Query hooks (one per REST endpoint)
363+
- `frontend/src/hooks/useStatsWebSocket.ts` — WebSocket hook that triggers query invalidation on push
364+
- `frontend/src/components/stats/` — 8 components: `RequestCountWidget`, `TopHostsChart`, `StatusDistributionChart`, `TrafficVolumeChart`, `CertExpiryList`, `ServiceHealthWidget`, `PeriodSelector`, `BucketSelector`
365+
332366
#### Caddy Manager (`internal/caddy/`)
333367

334368
- **Manager:** Orchestrates Caddy configuration updates
@@ -373,6 +407,7 @@ graph TB
373407
- **User:** Authentication and authorization
374408
- **Setting:** Key-value configuration storage
375409
- **ImportSession:** Import job tracking
410+
- **RequestLog:** Per-request stats record (HostID, Timestamp, Method, StatusCode, BytesSent, DurationMs, ClientIPHash)
376411

377412
### 2. Frontend (React + TypeScript)
378413

@@ -823,6 +858,30 @@ sequenceDiagram
823858
B->>L: Unsubscribe
824859
```
825860

861+
### Stats Ingestion & Push
862+
863+
```mermaid
864+
sequenceDiagram
865+
participant C as Caddy Proxy
866+
participant LW as LogWatcher (fan-out)
867+
participant SI as StatsIngester
868+
participant DB as SQLite (request_logs)
869+
participant SS as StatsService (30s TTL cache)
870+
participant WS as StatsWSHub
871+
participant F as Frontend (React)
872+
873+
C->>LW: Log entry (access log)
874+
LW->>SI: Fan-out channel (non-blocking)
875+
SI->>SI: Buffer (500ms or 100 entries)
876+
SI->>DB: Batch INSERT request_logs
877+
SI->>WS: Notify hub (stats changed)
878+
WS->>F: Push StatsPushMessage (WebSocket)
879+
F->>SS: GET /api/stats/summary (poll or on push)
880+
SS->>DB: Aggregation query (cached 30s)
881+
DB-->>SS: Results
882+
SS-->>F: StatsSummary JSON
883+
```
884+
826885
---
827886

828887
## Deployment Architecture

backend/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ require (
7070
github.com/opencontainers/go-digest v1.0.0 // indirect
7171
github.com/opencontainers/image-spec v1.1.1 // indirect
7272
github.com/oschwald/maxminddb-golang/v2 v2.4.0 // indirect
73-
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
73+
github.com/pelletier/go-toml/v2 v2.4.0 // indirect
7474
github.com/pmezard/go-difflib v1.0.0 // indirect
7575
github.com/prometheus/client_model v0.6.2 // indirect
76-
github.com/prometheus/common v0.68.1 // indirect
76+
github.com/prometheus/common v0.69.0 // indirect
7777
github.com/prometheus/procfs v0.20.1 // indirect
7878
github.com/quic-go/qpack v0.6.0 // indirect
7979
github.com/quic-go/quic-go v0.60.0 // indirect

backend/go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,16 @@ github.com/oschwald/geoip2-golang/v2 v2.2.0 h1:gdkhpnHQMiH9ymOI+zSB0QKFGH+n4TntN
118118
github.com/oschwald/geoip2-golang/v2 v2.2.0/go.mod h1:xW4tCeQiNU1gqMD1x7zEH2CDNM3d796Ls50yxYDaX0U=
119119
github.com/oschwald/maxminddb-golang/v2 v2.4.0 h1:3ftnrR1/XwiQ788bWIRhsE1DK3GOgJ6tm6S2qTktLm8=
120120
github.com/oschwald/maxminddb-golang/v2 v2.4.0/go.mod h1:7jcFtmhWVDEV+UopVv9NjcPm200uMyEHN14LIVV4hW8=
121-
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
122-
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
121+
github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row=
122+
github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
123123
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
124124
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
125125
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
126126
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
127127
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
128128
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
129-
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
130-
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
129+
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
130+
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
131131
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
132132
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
133133
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
package handlers
2+
3+
// stats_api_integration_test.go — end-to-end stats API tests with a real
4+
// SQLite :memory: database. Unlike the unit tests in stats_handler_test.go,
5+
// these tests seed RequestLog and SSL/ProxyHost rows first and then assert
6+
// that the HTTP responses contain the expected seeded data.
7+
8+
import (
9+
"encoding/json"
10+
"net/http"
11+
"net/http/httptest"
12+
"testing"
13+
"time"
14+
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
18+
"github.com/Wikid82/charon/backend/internal/models"
19+
"github.com/Wikid82/charon/backend/internal/services"
20+
)
21+
22+
// openSeededStatsDB creates an in-memory DB, migrates stats-related models, and
23+
// returns a fully-wired StatsHandler ready for HTTP testing.
24+
func openSeededStatsDB(t *testing.T) *StatsHandler {
25+
t.Helper()
26+
db := OpenTestDB(t)
27+
require.NoError(t, db.AutoMigrate(
28+
&models.RequestLog{},
29+
&models.ProxyHost{},
30+
&models.SSLCertificate{},
31+
))
32+
33+
now := time.Now().UTC()
34+
35+
// Seed a variety of RequestLog rows across different time windows.
36+
logs := []models.RequestLog{
37+
// Last 24 h — host-a dominates
38+
{HostID: "host-a", Timestamp: now.Add(-1 * time.Hour), Method: "GET", StatusCode: 200, BytesSent: 1024, DurationMs: 10},
39+
{HostID: "host-a", Timestamp: now.Add(-2 * time.Hour), Method: "POST", StatusCode: 201, BytesSent: 512, DurationMs: 20},
40+
{HostID: "host-a", Timestamp: now.Add(-3 * time.Hour), Method: "GET", StatusCode: 200, BytesSent: 256, DurationMs: 8},
41+
{HostID: "host-b", Timestamp: now.Add(-4 * time.Hour), Method: "GET", StatusCode: 404, BytesSent: 128, DurationMs: 5},
42+
// Last 7 d (but older than 24 h)
43+
{HostID: "host-a", Timestamp: now.Add(-48 * time.Hour), Method: "GET", StatusCode: 500, BytesSent: 2048, DurationMs: 30},
44+
{HostID: "host-b", Timestamp: now.Add(-72 * time.Hour), Method: "DELETE", StatusCode: 204, BytesSent: 0, DurationMs: 8},
45+
// Last 30 d (but older than 7 d)
46+
{HostID: "host-c", Timestamp: now.Add(-10 * 24 * time.Hour), Method: "GET", StatusCode: 200, BytesSent: 4096, DurationMs: 15},
47+
{HostID: "host-c", Timestamp: now.Add(-20 * 24 * time.Hour), Method: "GET", StatusCode: 200, BytesSent: 8192, DurationMs: 25},
48+
}
49+
require.NoError(t, db.Create(&logs).Error)
50+
51+
svc := services.NewStatsService(db)
52+
ingester := services.NewStatsIngester(db)
53+
h := NewStatsHandlerFull(svc, ingester, nil)
54+
return h
55+
}
56+
57+
// TestStatsAPI_Summary_SeededCounts verifies GET /api/stats/summary returns
58+
// the correct request counts matching the seeded data.
59+
func TestStatsAPI_Summary_SeededCounts(t *testing.T) {
60+
h := openSeededStatsDB(t)
61+
r := setupStatsRouter(h)
62+
63+
w := httptest.NewRecorder()
64+
req, _ := http.NewRequest(http.MethodGet, "/api/stats/summary", nil)
65+
r.ServeHTTP(w, req)
66+
67+
require.Equal(t, http.StatusOK, w.Code)
68+
69+
var body map[string]any
70+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
71+
72+
// 4 logs within 24 h
73+
assert.InDelta(t, 4, body["requests_last_24h"], 0, "expected 4 requests in last 24h")
74+
// 4 + 2 = 6 within 7 d
75+
assert.InDelta(t, 6, body["requests_last_7d"], 0, "expected 6 requests in last 7d")
76+
// all 8 within 30 d
77+
assert.InDelta(t, 8, body["requests_last_30d"], 0, "expected 8 requests in last 30d")
78+
}
79+
80+
// TestStatsAPI_TopHosts_24h verifies GET /api/stats/top-hosts?period=24h
81+
// returns host-a as the top host (3 requests in last 24h vs host-b's 1).
82+
func TestStatsAPI_TopHosts_24h(t *testing.T) {
83+
h := openSeededStatsDB(t)
84+
r := setupStatsRouter(h)
85+
86+
w := httptest.NewRecorder()
87+
req, _ := http.NewRequest(http.MethodGet, "/api/stats/top-hosts?period=24h", nil)
88+
r.ServeHTTP(w, req)
89+
90+
require.Equal(t, http.StatusOK, w.Code)
91+
92+
var results []map[string]any
93+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &results))
94+
require.NotEmpty(t, results, "expected at least one top-host entry")
95+
96+
// host-a should be first because it has 3 requests in the 24h window
97+
topHost, ok := results[0]["host_id"].(string)
98+
require.True(t, ok, "host_id should be a string")
99+
assert.Equal(t, "host-a", topHost)
100+
}
101+
102+
// TestStatsAPI_StatusDistribution_7d verifies GET /api/stats/status-distribution?period=7d
103+
// includes the 200, 201, 404, 500, and 204 status codes present in the 7d window.
104+
func TestStatsAPI_StatusDistribution_7d(t *testing.T) {
105+
h := openSeededStatsDB(t)
106+
r := setupStatsRouter(h)
107+
108+
w := httptest.NewRecorder()
109+
req, _ := http.NewRequest(http.MethodGet, "/api/stats/status-distribution?period=7d", nil)
110+
r.ServeHTTP(w, req)
111+
112+
require.Equal(t, http.StatusOK, w.Code)
113+
114+
var results []map[string]any
115+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &results))
116+
require.NotEmpty(t, results, "expected status distribution entries")
117+
118+
// Collect all returned status codes
119+
codes := make(map[int]bool)
120+
for _, entry := range results {
121+
if code, ok := entry["code"].(float64); ok {
122+
codes[int(code)] = true
123+
}
124+
}
125+
126+
assert.True(t, codes[200], "expected status code 200 in distribution")
127+
assert.True(t, codes[201], "expected status code 201 in distribution")
128+
assert.True(t, codes[404], "expected status code 404 in distribution")
129+
}
130+
131+
// TestStatsAPI_TrafficVolume_1h verifies GET /api/stats/traffic-volume?bucket=1h
132+
// returns bucket data with the expected fields present.
133+
func TestStatsAPI_TrafficVolume_1h(t *testing.T) {
134+
h := openSeededStatsDB(t)
135+
r := setupStatsRouter(h)
136+
137+
w := httptest.NewRecorder()
138+
req, _ := http.NewRequest(http.MethodGet, "/api/stats/traffic-volume?bucket=1h", nil)
139+
r.ServeHTTP(w, req)
140+
141+
require.Equal(t, http.StatusOK, w.Code)
142+
143+
var results []map[string]any
144+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &results))
145+
146+
// Results may be empty if no logs fall in a full 1h bucket, but the response
147+
// shape must be a JSON array (not null or an error object).
148+
assert.NotNil(t, results, "traffic volume response must be a JSON array")
149+
150+
// Validate shape of any returned bucket
151+
for _, bucket := range results {
152+
assert.Contains(t, bucket, "bucket", "each bucket must have a 'bucket' timestamp field")
153+
assert.Contains(t, bucket, "bytes_sent", "each bucket must have a 'bytes_sent' field")
154+
}
155+
}
156+
157+
// TestStatsAPI_CertExpiry_30Days verifies GET /api/stats/cert-expiry?within_days=30
158+
// returns a JSON array (certificates expiring soon, if any).
159+
func TestStatsAPI_CertExpiry_30Days(t *testing.T) {
160+
h := openSeededStatsDB(t)
161+
r := setupStatsRouter(h)
162+
163+
w := httptest.NewRecorder()
164+
req, _ := http.NewRequest(http.MethodGet, "/api/stats/cert-expiry?within_days=30", nil)
165+
r.ServeHTTP(w, req)
166+
167+
require.Equal(t, http.StatusOK, w.Code)
168+
169+
// Response must decode as a JSON array (may be empty for a fresh test DB).
170+
var results []map[string]any
171+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &results))
172+
assert.NotNil(t, results)
173+
}
174+
175+
// TestStatsAPI_CertExpiry_WithExpiringSoon verifies that a certificate expiring
176+
// within the threshold window is included in the cert-expiry response.
177+
func TestStatsAPI_CertExpiry_WithExpiringSoon(t *testing.T) {
178+
db := OpenTestDB(t)
179+
require.NoError(t, db.AutoMigrate(
180+
&models.RequestLog{},
181+
&models.ProxyHost{},
182+
&models.SSLCertificate{},
183+
))
184+
185+
// Seed a certificate that expires in 15 days (within the 30-day window).
186+
expiresAt := time.Now().UTC().Add(15 * 24 * time.Hour)
187+
cert := models.SSLCertificate{
188+
UUID: "cert-uuid-expiring-soon",
189+
Name: "Expiring Soon Cert",
190+
ExpiresAt: &expiresAt,
191+
}
192+
require.NoError(t, db.Create(&cert).Error)
193+
194+
// Seed a proxy host linked to that certificate.
195+
host := models.ProxyHost{
196+
UUID: "host-uuid-expiring",
197+
DomainNames: "expiring.example.com",
198+
ForwardHost: "10.0.0.1",
199+
ForwardPort: 80,
200+
CertificateID: &cert.ID,
201+
}
202+
require.NoError(t, db.Create(&host).Error)
203+
204+
svc := services.NewStatsService(db)
205+
h := NewStatsHandler(svc)
206+
r := setupStatsRouter(h)
207+
208+
w := httptest.NewRecorder()
209+
req, _ := http.NewRequest(http.MethodGet, "/api/stats/cert-expiry?within_days=30", nil)
210+
r.ServeHTTP(w, req)
211+
212+
require.Equal(t, http.StatusOK, w.Code)
213+
214+
var results []map[string]any
215+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &results))
216+
require.NotEmpty(t, results, "expected at least one cert expiry entry")
217+
218+
// Verify response shape fields
219+
first := results[0]
220+
assert.Contains(t, first, "host_id")
221+
assert.Contains(t, first, "hostname")
222+
assert.Contains(t, first, "expires_at")
223+
assert.Contains(t, first, "days_left")
224+
}
225+
226+
// TestStatsAPI_CertExpiry_ZeroDays_Returns400 verifies that within_days=0
227+
// is rejected with HTTP 400.
228+
func TestStatsAPI_CertExpiry_ZeroDays_Returns400(t *testing.T) {
229+
h := openSeededStatsDB(t)
230+
r := setupStatsRouter(h)
231+
232+
w := httptest.NewRecorder()
233+
req, _ := http.NewRequest(http.MethodGet, "/api/stats/cert-expiry?within_days=0", nil)
234+
r.ServeHTTP(w, req)
235+
236+
assert.Equal(t, http.StatusBadRequest, w.Code)
237+
238+
var body map[string]any
239+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
240+
assert.Contains(t, body, "error")
241+
}
242+
243+
// TestStatsAPI_CertExpiry_366Days_Returns400 verifies that within_days=366
244+
// is rejected with HTTP 400 because the upper bound is 365.
245+
func TestStatsAPI_CertExpiry_366Days_Returns400(t *testing.T) {
246+
h := openSeededStatsDB(t)
247+
r := setupStatsRouter(h)
248+
249+
w := httptest.NewRecorder()
250+
req, _ := http.NewRequest(http.MethodGet, "/api/stats/cert-expiry?within_days=366", nil)
251+
r.ServeHTTP(w, req)
252+
253+
assert.Equal(t, http.StatusBadRequest, w.Code)
254+
255+
var body map[string]any
256+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
257+
assert.Contains(t, body, "error")
258+
}
259+
260+
// TestStatsAPI_Health_DroppedCountPresent verifies GET /api/stats/health
261+
// includes the dropped_count field and returns HTTP 200.
262+
func TestStatsAPI_Health_DroppedCountPresent(t *testing.T) {
263+
db := OpenTestDB(t)
264+
require.NoError(t, db.AutoMigrate(&models.RequestLog{}))
265+
266+
svc := services.NewStatsService(db)
267+
ingester := services.NewStatsIngester(db)
268+
h := NewStatsHandlerFull(svc, ingester, nil)
269+
r := setupStatsRouter(h)
270+
271+
w := httptest.NewRecorder()
272+
req, _ := http.NewRequest(http.MethodGet, "/api/stats/health", nil)
273+
r.ServeHTTP(w, req)
274+
275+
require.Equal(t, http.StatusOK, w.Code)
276+
277+
var body map[string]any
278+
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
279+
assert.Contains(t, body, "dropped_count", "health response must include dropped_count")
280+
281+
// Fresh ingester should report 0 dropped events
282+
assert.InDelta(t, float64(0), body["dropped_count"], 0, "expected 0 dropped events for a fresh ingester")
283+
}

0 commit comments

Comments
 (0)