Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ curl -s "http://<PI_IP>: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
Expand Down Expand Up @@ -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

Expand All @@ -167,4 +167,4 @@ Across routed subnets the Pi rarely learns remote client MACs, so queries record
| `dig @<PI_IP>` 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` |
66 changes: 33 additions & 33 deletions internal/store/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions internal/store/json_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading