diff --git a/CHANGELOG.md b/CHANGELOG.md index 5703214..74dff4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- **Breaking:** API responses now use snake_case JSON keys (e.g. `device_mac`); update consumers relying on the old PascalCase keys. + ## [0.1.0] - 2026-04-19 Initial public release. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d2de9b3..ce084b8 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -101,7 +101,7 @@ curl -s "http://:8080/api/queries?limit=20" Expected result: - `/api/health` returns `{"status":"ok"}` -- `TotalQueries` increases after test lookups +- `total_queries` increases after test lookups - `/api/queries` shows fresh domains and timestamps ### 5. Confirm the UI reflects the API @@ -157,7 +157,7 @@ sudo tcpdump -D ### Cross-subnet attribution uses source-IP fallback -Across routed subnets the Pi rarely learns remote client MACs, so queries record `SourceIP` with `DeviceMAC` blank. Those appear as source-IP fallback actors in the Devices page and via `/api/actors`. +Across routed subnets the Pi rarely learns remote client MACs, so queries record `source_ip` with `device_mac` blank. Those appear as source-IP fallback actors in the Devices page and via `/api/actors`. ## Result Triage @@ -167,4 +167,4 @@ Across routed subnets the Pi rarely learns remote client MACs, so queries record | `dig @` succeeds, `tcpdump` on Pi sees nothing | gateway-level DNS interception, wrong capture context, or wrong host/interface assumption | | `tcpdump` shows DNS packets, `/api/queries` is empty | query ingestion/storage issue | | `/api/queries` has rows, UI is stale/empty | UI rendering/filter issue | -| `/api/queries` has `SourceIP` but blank `DeviceMAC` | expected routed-subnet behavior; verify source-IP fallback actor is present in UI or `/api/actors` | +| `/api/queries` has `source_ip` but blank `device_mac` | expected routed-subnet behavior; verify source-IP fallback actor is present in UI or `/api/actors` | diff --git a/internal/store/db.go b/internal/store/db.go index 8ead127..a1150f2 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -65,23 +65,23 @@ func backfillHourlyRollups(conn *sql.DB) error { } type Device struct { - MAC string - IP string - Hostname string - Vendor string - Label string - FirstSeen time.Time - LastSeen time.Time + MAC string `json:"mac"` + IP string `json:"ip"` + Hostname string `json:"hostname"` + Vendor string `json:"vendor"` + Label string `json:"label"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` } type Query struct { - ID int64 - DeviceMAC string - SourceIP string - Domain string - QueryType string - Category string - Timestamp time.Time + ID int64 `json:"id"` + DeviceMAC string `json:"device_mac"` + SourceIP string `json:"source_ip"` + Domain string `json:"domain"` + QueryType string `json:"query_type"` + Category string `json:"category"` + Timestamp time.Time `json:"timestamp"` } type QueryFeedFilter struct { @@ -778,12 +778,12 @@ func (d *DB) AddList(url, name, category string) (int64, error) { } type ListEntry struct { - ID int64 - URL string - Name string - Category string - LastFetch *time.Time - Enabled bool + ID int64 `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + Category string `json:"category"` + LastFetch *time.Time `json:"last_fetch"` + Enabled bool `json:"enabled"` } func (d *DB) ListLists() ([]ListEntry, error) { @@ -948,20 +948,20 @@ func (d *DB) ListDomainOverrides() (map[string]string, error) { } type DashboardStats struct { - TotalQueries int - TrackerPercent float64 - DeviceCount int - UniqueDomainCount int - TopDevices []DeviceSummary + TotalQueries int `json:"total_queries"` + TrackerPercent float64 `json:"tracker_percent"` + DeviceCount int `json:"device_count"` + UniqueDomainCount int `json:"unique_domain_count"` + TopDevices []DeviceSummary `json:"top_devices"` } type DeviceSummary struct { - MAC string - Hostname string - Vendor string - Label string - QueryCount int - TrackerPercent float64 + MAC string `json:"mac"` + Hostname string `json:"hostname"` + Vendor string `json:"vendor"` + Label string `json:"label"` + QueryCount int `json:"query_count"` + TrackerPercent float64 `json:"tracker_percent"` } func (d *DB) DashboardSummary() (DashboardStats, error) { @@ -1056,8 +1056,8 @@ func (d *DB) DeviceCategoryBreakdown(mac string) ([]CategoryCount, error) { type DeviceWithStats struct { Device - QueryCount int - TrackerPercent float64 + QueryCount int `json:"query_count"` + TrackerPercent float64 `json:"tracker_percent"` } type DeviceWithTrends struct { diff --git a/internal/store/json_test.go b/internal/store/json_test.go new file mode 100644 index 0000000..8510eee --- /dev/null +++ b/internal/store/json_test.go @@ -0,0 +1,36 @@ +package store + +import ( + "encoding/json" + "strings" + "testing" +) + +// These structs are serialized directly as API responses, so their JSON keys +// are part of the public contract and must stay snake_case. Each case checks a +// representative compound key. +func TestAPIResponseStructsUseSnakeCase(t *testing.T) { + cases := []struct { + name string + value any + want string + }{ + {"Query", Query{}, `"device_mac"`}, + {"Device", Device{}, `"first_seen"`}, + {"DeviceWithStats", DeviceWithStats{}, `"query_count"`}, + {"DashboardStats", DashboardStats{}, `"total_queries"`}, + {"DeviceSummary", DeviceSummary{}, `"query_count"`}, + {"ListEntry", ListEntry{}, `"last_fetch"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data, err := json.Marshal(tc.value) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := string(data); !strings.Contains(got, tc.want) { + t.Errorf("missing %s in %s", tc.want, got) + } + }) + } +}