Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
3e2a4f1
Correct name for `Mikrotik RouterOS`.
lamtrinhdev Sep 3, 2026
e8e9856
Merge remote-tracking branch 'moddns/main' into develop
MaciejTe Sep 7, 2026
2308c5b
docs(app): add Privacy FAQ section with QNAME minimisation and EDNS C…
MaciejTe Sep 9, 2026
b0f6df1
docs(app): explain how to get modDNS in the FAQ Basics section
MaciejTe Sep 9, 2026
f76068e
docs(app): state that QNAME minimisation is always on in the FAQ
MaciejTe Sep 9, 2026
87586f9
docs(app): refresh the FAQ last-updated date
MaciejTe Sep 9, 2026
f4e01ae
fix(app): Remove duplicate Limited Access banners on Account and Prof…
MaciejTe Sep 9, 2026
e1de5cd
Merge pull request #258 from lamtrinhdev/Update_Routers.tsx
MaciejTe Sep 10, 2026
46610d7
Merge pull request #263 from ivpn/fix/limited-access-banner-shown-twice
MaciejTe Sep 10, 2026
a006cba
fix(dnscheck): open GeoIP reader once and degrade instead of crashing…
MaciejTe Sep 4, 2026
7d4ce8a
chore(dnscheck): Remove unused maxmind code
MaciejTe Sep 7, 2026
031551b
fix(dnscheck): stop storing and logging client identifiers, wire cach…
MaciejTe Sep 7, 2026
bfb6d4e
feat(dnscheck): accept a comma-separated CIDR list for the trusted IP…
MaciejTe Sep 7, 2026
c38cc68
feat(dnscheck): accept a labelled comma-separated CIDR list for the t…
MaciejTe Sep 7, 2026
84d95f8
fix(app,dnscheck): stop embedding the profile ID in the connection-ch…
MaciejTe Sep 7, 2026
21db4ea
test(e2e): unskip the dnscheck connection-status suite and route it i…
MaciejTe Sep 7, 2026
349f758
Merge pull request #259 from ivpn/fix/dnscheck-nil-pointer-crash
MaciejTe Sep 10, 2026
b02a14a
fix(proxy): answer SERVFAIL on settings-store errors instead of dropp…
MaciejTe Sep 8, 2026
4d3ca7a
feat(app): label the filter_unavailable query-log outcome
MaciejTe Sep 8, 2026
9cbdda5
perf(proxy): carry per-profile filter inputs on the request context f…
MaciejTe Sep 8, 2026
ebf4e07
feat(proxy): serve last-known-good profile settings while Redis is un…
MaciejTe Sep 8, 2026
ac432d0
feat(libs): add an optional per-operation Redis command timeout
MaciejTe Sep 8, 2026
3a28517
fix(proxy): bound Redis command timeouts and filter store deadlines
MaciejTe Sep 8, 2026
e3daa5c
fix(libs): apply context deadlines to Redis socket I/O with the comma…
MaciejTe Sep 8, 2026
80cbc47
refactor(proxy): own metric label values in the metrics package
MaciejTe Sep 8, 2026
168661d
fix(proxy): request-scoped store deadlines, transition-only outage lo…
MaciejTe Sep 8, 2026
122efcd
feat(api): accept the unavailable query-log status filter
MaciejTe Sep 8, 2026
c94c147
feat(app): render and filter unavailable query-log rows
MaciejTe Sep 8, 2026
e8ac46d
test(proxy): add end-to-end filter pipeline benchmark against a real …
MaciejTe Sep 8, 2026
a6dccb5
feat(proxy): expose settings cache occupancy, evictions and store ava…
MaciejTe Sep 9, 2026
6bb4fa6
test(proxy): reference the profile settings cache spec rows from the …
MaciejTe Sep 9, 2026
e5b0bf5
feat(api): add the unanswered query-log status filter selecting every…
MaciejTe Sep 10, 2026
7132cf2
feat(app): replace the Unavailable logs filter with No answer and dro…
MaciejTe Sep 10, 2026
5ae5b07
fix(app): Keep group folders visible when no rules exist
MaciejTe Sep 10, 2026
a042bd2
refactor(api): drop the unavailable query-log status filter value; ro…
MaciejTe Sep 11, 2026
d658c27
Merge pull request #261 from ivpn/fix/redis-errors-handling
MaciejTe Sep 11, 2026
59ec6ff
Merge pull request #264 from ivpn/fix/custom-rules-folders-disappear
MaciejTe Sep 11, 2026
ac2895c
fix(blocklists): honor $badfilter in the AdGuard extractor instead of…
MaciejTe Sep 2, 2026
953f915
chore(blocklists): run main's defers before exit (gocritic exitAfterD…
MaciejTe Sep 2, 2026
dff64a1
feat(blocklists): fail open on unsupported AdGuard modifiers and coun…
MaciejTe Sep 2, 2026
3f25b6f
test(proxy): pin profile-ID character-class validation and SNI part s…
MaciejTe Sep 3, 2026
95081c3
feat(blocklists): extract AdGuard @@ exception rules into per-source …
MaciejTe Sep 3, 2026
8f2f8d8
feat(proxy): withdraw a blocklist match covered by the list's own exc…
MaciejTe Sep 3, 2026
dd38f80
Merge pull request #256 from ivpn/feat/enrich-adguard-extractor
MaciejTe Sep 16, 2026
1482c1b
Merge pull request #262 from ivpn/chore/faq-privacy
MaciejTe Sep 16, 2026
da22560
feat(proxy): replace per-profile statistics with anonymous per-PoP se…
MaciejTe Sep 15, 2026
48fd7ed
feat(api): add migration 026 clearing the legacy per-profile statisti…
MaciejTe Sep 15, 2026
3ec9180
test(e2e): cover the anonymous per-PoP service_statistics collection
MaciejTe Sep 15, 2026
adcee15
test(e2e): add pymongo, move to dnspython 2.8 and bootstrap the DoH e…
MaciejTe Sep 16, 2026
dcefe94
docs(app): describe service-wide DNS statistics in the Privacy Policy
MaciejTe Sep 16, 2026
c40a8b7
fix(app): opt the FAQ page out of scroll anchoring
MaciejTe Sep 16, 2026
66979e3
fix(app): drop nested scroll containers from the public layout and FA…
MaciejTe Sep 16, 2026
db569a4
fix(app): clip collapsed FAQ answers so they stop stretching the page
MaciejTe Sep 16, 2026
24c6066
feat(proxy): store service_statistics as one hourly document per PoP …
MaciejTe Sep 17, 2026
e678f54
docs(api): describe the service_statistics collection shape in the mi…
MaciejTe Sep 17, 2026
05c4063
docs(app): describe service-wide DNS statistics in the Privacy Policy
MaciejTe Sep 17, 2026
bfa0b9b
test(e2e): cover the hourly service_statistics document per PoP
MaciejTe Sep 17, 2026
8bfb03b
Merge pull request #266 from ivpn/fix/ios-scrolling
MaciejTe Sep 18, 2026
2f19137
fix(app): let the empty add-rule input fill its field so browsers off…
MaciejTe Sep 18, 2026
e726535
docs(app): Update Privacy Policy
MaciejTe Sep 18, 2026
ae6b656
Merge pull request #265 from ivpn/chore/service-statistics
MaciejTe Sep 21, 2026
a25a85c
test(e2e): read the clock once when deriving the service_statistics h…
MaciejTe Sep 21, 2026
3eb9b00
Merge pull request #269 from ivpn/tests/fix-flaky-test
MaciejTe Sep 21, 2026
5984115
Merge pull request #267 from ivpn/fix/copy-paste-add-field
MaciejTe Sep 21, 2026
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: 2 additions & 2 deletions api/api/query_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
// @Param id path string true "Profile ID"
// @Param page query int false "specify page number" default(1)
// @Param limit query int false "specify logs limit by page" default(100)
// @Param status query string false "specify status for query" default("all")
// @Param status query string false "specify status for query" Enums(all,blocked,processed,unanswered) default("all")
// @Param timespan query string false "specify timespan for query" default("LAST_1_HOUR")
// @Param device_id query string false "specify device ID for filtering"
// @Param search query string false "substring (case-insensitive) match against stored domain; free-form (short inputs may scan more)"
Expand All @@ -36,7 +36,7 @@ func (s *APIServer) getProfileQueryLogs() fiber.Handler {
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 25),
Timespan: c.Query("timespan", model.LAST_1_HOUR),
Status: c.Query("status", "all"),
Status: c.Query("status", model.QueryLogStatusAll),
DeviceId: c.Query("device_id", ""),
Search: c.Query("search", ""),
SortBy: c.Query("sort_by", "created"),
Expand Down
26 changes: 26 additions & 0 deletions api/api/query_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,32 @@ func (s *QueryLogsAPIShortSuite) auth(req *http.Request) {
s.db.On("GetSession", mock.Anything, qlSessTok).Return(model.Session{AccountID: qlAccID}, true, nil)
}

// tableRef: query-log-outcomes-behaviour.md #C5
func (s *QueryLogsAPIShortSuite) TestGetLogsUnansweredStatusAccepted() {
logs := []model.QueryLog{{ProfileID: qlProfile, Status: "processed", Outcome: "timeout", Timestamp: time.Now(), DNSRequest: model.DNSRequest{Domain: "example.com"}}}
s.svc.On("GetProfileQueryLogs", mock.Anything, qlAccID, qlProfile, "unanswered", "LAST_1_HOUR", "", "", "created", 1, 25).Return(logs, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/"+qlProfile+"/logs?page=1&limit=25&status=unanswered&timespan=LAST_1_HOUR", nil)
s.auth(req)
resp, err := s.server().App.Test(req, -1)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
var out []model.QueryLog
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&out))
assert.Len(s.T(), out, 1)
}

// specRef: query-log-outcomes-behaviour.md #C4 — the row status "unavailable"
// is not a filter value; those rows are reached through "unanswered".
func (s *QueryLogsAPIShortSuite) TestGetLogsUnknownStatusRejected() {
for _, status := range []string{"dropped", "unavailable"} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/profiles/"+qlProfile+"/logs?page=1&limit=25&status="+status+"&timespan=LAST_1_HOUR", nil)
s.auth(req)
resp, err := s.server().App.Test(req, -1)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode, status)
}
}

func (s *QueryLogsAPIShortSuite) TestGetLogsSuccess() {
logs := []model.QueryLog{{ProfileID: qlProfile, Status: "processed", Timestamp: time.Now(), DNSRequest: model.DNSRequest{Domain: "example.com"}}}
s.svc.On("GetProfileQueryLogs", mock.Anything, qlAccID, qlProfile, "processed", "LAST_1_HOUR", "", "", "created", 1, 25).Return(logs, nil)
Expand Down
2 changes: 1 addition & 1 deletion api/api/requests/query_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type QueryLogsQueryParams struct {
Page int `json:"page" validate:"required,numeric,min=1"`
Limit int `json:"limit" validate:"required,oneof=10 25 50 100"`
Timespan string `json:"timespan" validate:"oneof=LAST_1_HOUR LAST_12_HOURS LAST_1_DAY LAST_7_DAYS LAST_MONTH"`
Status string `json:"status" validate:"omitempty,oneof=all blocked processed"`
Status string `json:"status" validate:"omitempty,oneof=all blocked processed unanswered"`
DeviceId string `json:"device_id" validate:"omitempty"`
Search string `json:"search" validate:"omitempty,max=256"`
SortBy string `json:"sort_by" validate:"oneof=created domain client_ip"`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[]
12 changes: 12 additions & 0 deletions api/db/mongodb/migrations/026_statistics_clear_legacy.up.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[
{
"delete": "statistics",
"deletes": [
{
"q": {},
"limit": 0
}
],
"writeConcern": { "w": "majority" }
}
]
12 changes: 12 additions & 0 deletions api/db/mongodb/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ the target environment: case-collision groups must be zero, and emails must be A
without surrounding whitespace (`$toLower` is ASCII-only; the migration does not trim).
Audit queries are in the PR that introduced the migration.

### Migration 026 (statistics clear)

Clears the legacy per-profile `statistics` time-series collection. Query statistics are
now service-wide and live in `service_statistics` (a regular collection the proxy upserts
into, one document per PoP and hour, no TTL);
per-profile statistics return with the Analytics page in a new shape. `delete` with an empty
filter, not `drop`, so a fresh database without the collection succeeds; an empty-filter
delete on a time-series collection needs MongoDB ≥ 7.0. Idempotent; the down migration is
a no-op. Deploy note: proxies still running the previous release between the DCN and DFN
restarts may recreate `statistics` as a plain collection; after the DFN restart, drop it if
`db.statistics.countDocuments({})` is non-zero.

### Query logs collections

Note: Query logs time-series collections are created by the proxy service. Their only index is the `{profile_id, timestamp}` meta+time index MongoDB creates automatically on time-series creation (≥6.3) — no code creates query-log indexes explicitly (verified against prod, moddns-shadow#688).
12 changes: 11 additions & 1 deletion api/db/mongodb/query_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ func (r *QueryLogsRepository) GetQueryLogs(ctx context.Context, profileId string
},
})
}
if status != "all" {
switch status {
case model.QueryLogStatusAll:
case model.QueryLogStatusUnanswered:
matchFilter = append(matchFilter, unansweredFilter())
default:
matchFilter = append(matchFilter, bson.E{
Key: "status",
Value: status,
Expand Down Expand Up @@ -247,3 +251,9 @@ func (r *QueryLogsRepository) getCollObject(retention model.Retention) *mongo.Co
return r.queryLogsCollOneHour
}
}

// unansweredFilter selects the "No answer" class by outcome
// (query-log-outcomes-behaviour.md C5).
func unansweredFilter() bson.E {
return bson.E{Key: "outcome", Value: bson.D{{Key: "$in", Value: model.UnansweredOutcomes}}}
}
6 changes: 6 additions & 0 deletions api/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -1790,6 +1790,12 @@ const docTemplate = `{
"in": "query"
},
{
"enum": [
"all",
"blocked",
"processed",
"unanswered"
],
"type": "string",
"default": "\"all\"",
"description": "specify status for query",
Expand Down
6 changes: 6 additions & 0 deletions api/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,12 @@
"in": "query"
},
{
"enum": [
"all",
"blocked",
"processed",
"unanswered"
],
"type": "string",
"default": "\"all\"",
"description": "specify status for query",
Expand Down
5 changes: 5 additions & 0 deletions api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2567,6 +2567,11 @@ paths:
type: integer
- default: '"all"'
description: specify status for query
enum:
- all
- blocked
- processed
- unanswered
in: query
name: status
type: string
Expand Down
41 changes: 41 additions & 0 deletions api/model/query_log_outcome.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package model

// Values of the logs endpoint's status filter. Blocked and processed match
// QueryLog.Status as written by the proxy; unanswered is a pseudo-status
// resolved by outcome (query-log-outcomes-behaviour.md C5). The proxy also
// writes status "unavailable" (O11); those rows are reached through
// unanswered, never selected by status.
const (
QueryLogStatusAll = "all"
QueryLogStatusBlocked = "blocked"
QueryLogStatusProcessed = "processed"
QueryLogStatusUnanswered = "unanswered"
)

// Resolution-outcome tokens written by the proxy into QueryLog.Outcome.
// Decision table: docs/specs/query-log-outcomes-behaviour.md (rows O1–O11).
const (
OutcomeResolved = "resolved"
OutcomeNoData = "nodata"
OutcomeNXDomain = "nxdomain"
OutcomeBlocked = "blocked"
OutcomeServfailDNSSEC = "servfail_dnssec"
OutcomeServfailUpstream = "servfail_upstream"
OutcomeTimeout = "timeout"
OutcomeNetworkError = "network_error"
OutcomeRefused = "refused"
OutcomeFilterUnavailable = "filter_unavailable"
)

// UnansweredOutcomes is the "No answer" class for the logs status filter,
// the same set the collapsed-row label uses (query-log-outcomes-behaviour.md
// C3, C5): the service could not answer. Deliberate verdicts — blocked, and
// DNSSEC validation failures — are not failures and stay out. Every stored
// row carries an outcome: the field predates the longest retention window.
var UnansweredOutcomes = []string{
OutcomeServfailUpstream,
OutcomeTimeout,
OutcomeNetworkError,
OutcomeRefused,
OutcomeFilterUnavailable,
}
8 changes: 1 addition & 7 deletions api/service/query_logs/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,6 @@ import (
"github.com/ivpn/dns/api/model"
)

const (
STATUS_ALL = "all"
STATUS_BLOCKED = "blocked"
STATUS_PROCESSED = "processed"
)

type QueryLogsService struct {
QueryLogsRepository repository.QueryLogsRepository
}
Expand All @@ -37,7 +31,7 @@ func (q *QueryLogsService) GetProfileQueryLogs(ctx context.Context, profileId st
}

func (q *QueryLogsService) DownloadProfileQueryLogs(ctx context.Context, profileId string, retention model.Retention, page, limit int) ([]model.QueryLog, error) {
logs, err := q.QueryLogsRepository.GetQueryLogs(ctx, profileId, retention, STATUS_ALL, 0, "", "", "created", page, limit)
logs, err := q.QueryLogsRepository.GetQueryLogs(ctx, profileId, retention, model.QueryLogStatusAll, 0, "", "", "created", page, limit)
if err != nil {
return nil, err
}
Expand Down
25 changes: 18 additions & 7 deletions api/service/query_logs/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ func (s *QueryLogsServiceSuite) seedQueryLogs(ctx context.Context) {
bson.D{{Key: "timestamp", Value: now.Add(-3 * time.Hour)}, {Key: "profile_id", Value: s.profileID}, {Key: "device_id", Value: "phone"}, {Key: "status", Value: "processed"}, {Key: "reasons", Value: bson.A{}}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "sub.example.com"}, {Key: "query_type", Value: "AAAA"}, {Key: "response_code", Value: "NOERROR"}, {Key: "dnssec", Value: true}}}, {Key: "client_ip", Value: "1.2.3.5"}, {Key: "protocol", Value: "udp"}},
bson.D{{Key: "timestamp", Value: now.Add(-25 * time.Hour)}, {Key: "profile_id", Value: s.profileID}, {Key: "device_id", Value: "laptop"}, {Key: "status", Value: "blocked"}, {Key: "reasons", Value: bson.A{"tracker"}}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "old.example.com"}, {Key: "query_type", Value: "A"}, {Key: "response_code", Value: "NOERROR"}, {Key: "dnssec", Value: false}}}, {Key: "client_ip", Value: "1.2.3.6"}, {Key: "protocol", Value: "udp"}}, // outside 1d timespan
bson.D{{Key: "timestamp", Value: now.Add(-1 * time.Hour)}, {Key: "profile_id", Value: s.profileID}, {Key: "device_id", Value: "tablet"}, {Key: "status", Value: "processed"}, {Key: "reasons", Value: bson.A{}}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "example.org"}, {Key: "query_type", Value: "A"}, {Key: "response_code", Value: "NXDOMAIN"}, {Key: "dnssec", Value: false}}}, {Key: "client_ip", Value: "1.2.3.7"}, {Key: "protocol", Value: "udp"}},
// specRef: query-log-outcomes-behaviour.md #O11 — answered SERVFAIL by the proxy, neither blocked nor processed
bson.D{{Key: "timestamp", Value: now.Add(-4 * time.Hour)}, {Key: "profile_id", Value: s.profileID}, {Key: "device_id", Value: "laptop"}, {Key: "status", Value: "unavailable"}, {Key: "reasons", Value: bson.A{}}, {Key: "outcome", Value: "filter_unavailable"}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "unavailable.example.net"}, {Key: "query_type", Value: "A"}, {Key: "response_code", Value: "SERVFAIL"}, {Key: "dnssec", Value: false}}}, {Key: "client_ip", Value: "1.2.3.8"}, {Key: "protocol", Value: "udp"}},
// "No answer" class (C5): the timeout row matches; the DNSSEC verdict and
// the outcome-less REFUSED row stand in for rows the filter must NOT match.
bson.D{{Key: "timestamp", Value: now.Add(-5 * time.Hour)}, {Key: "profile_id", Value: s.profileID}, {Key: "device_id", Value: "laptop"}, {Key: "status", Value: "processed"}, {Key: "reasons", Value: bson.A{}}, {Key: "outcome", Value: "timeout"}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "timeout.unanswered.test"}, {Key: "query_type", Value: "A"}, {Key: "response_code", Value: "SERVFAIL"}, {Key: "dnssec", Value: false}}}, {Key: "client_ip", Value: "1.2.3.8"}, {Key: "protocol", Value: "udp"}},
bson.D{{Key: "timestamp", Value: now.Add(-6 * time.Hour)}, {Key: "profile_id", Value: s.profileID}, {Key: "device_id", Value: "laptop"}, {Key: "status", Value: "processed"}, {Key: "reasons", Value: bson.A{"dnssec_failed"}}, {Key: "outcome", Value: "servfail_dnssec"}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "dnssec.unanswered.test"}, {Key: "query_type", Value: "A"}, {Key: "response_code", Value: "SERVFAIL"}, {Key: "dnssec", Value: false}}}, {Key: "client_ip", Value: "1.2.3.8"}, {Key: "protocol", Value: "udp"}},
bson.D{{Key: "timestamp", Value: now.Add(-7 * time.Hour)}, {Key: "profile_id", Value: s.profileID}, {Key: "device_id", Value: "laptop"}, {Key: "status", Value: "processed"}, {Key: "reasons", Value: bson.A{}}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "legacy.unanswered.test"}, {Key: "query_type", Value: "A"}, {Key: "response_code", Value: "REFUSED"}, {Key: "dnssec", Value: false}}}, {Key: "client_ip", Value: "1.2.3.8"}, {Key: "protocol", Value: "udp"}},
// Another profile for isolation
bson.D{{Key: "timestamp", Value: now.Add(-2 * time.Hour)}, {Key: "profile_id", Value: "other-profile"}, {Key: "device_id", Value: "laptop"}, {Key: "status", Value: "blocked"}, {Key: "reasons", Value: bson.A{"malware"}}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "example.com"}, {Key: "query_type", Value: "A"}, {Key: "response_code", Value: "NOERROR"}, {Key: "dnssec", Value: false}}}, {Key: "client_ip", Value: "9.9.9.9"}, {Key: "protocol", Value: "udp"}},
}
Expand Down Expand Up @@ -218,7 +225,11 @@ func (s *QueryLogsServiceSuite) TestGetProfileQueryLogs() {
{"blocked search example within 1d", "blocked", "LAST_1_DAY", "", "example", "created", 0, 0, 1, "example"},
// Only sub.example.com matches processed status; example.com is blocked. Expect 1 result.
{"processed search com within 1d", "processed", "LAST_1_DAY", "", "com", "created", 0, 0, 1, "com"},
{"all no search within 1d", "all", "LAST_1_DAY", "", "", "created", 0, 0, 3, ""}, // excludes old.chatgpt.com outside 1d
{"all no search within 1d", "all", "LAST_1_DAY", "", "", "created", 0, 0, 7, ""}, // excludes old.example.com outside 1d
// tableRef: query-log-outcomes-behaviour.md #C5 — outcome-based class (C3 set); DNSSEC verdicts and rows without an outcome are not matched
{"unanswered selects every no-answer outcome", "unanswered", "LAST_1_DAY", "", "", "created", 0, 0, 2, ""},
{"unanswered excludes resolved and blocked rows", "unanswered", "LAST_1_DAY", "", "example.com", "created", 0, 0, 0, ""},
{"unanswered combines with device filter", "unanswered", "LAST_1_DAY", "tablet", "", "created", 0, 0, 0, ""},
{"device filtered processed", "processed", "LAST_1_DAY", "tablet", "", "created", 0, 0, 1, "example.org"},
{"pagination first page size 1", "processed", "LAST_1_DAY", "", "com", "created", 1, 1, 1, "com"},
{"search miss returns empty", "blocked", "LAST_1_DAY", "", "nomatch", "created", 0, 0, 0, ""},
Expand All @@ -245,23 +256,23 @@ func (s *QueryLogsServiceSuite) TestGetProfileQueryLogsSorting() {
s.Run("domain ascending", func() {
logs, err := s.service.GetProfileQueryLogs(ctx, s.profileID, retention, "all", "LAST_7_DAYS", "", "", "domain", 0, 0)
s.Require().NoError(err)
s.Equal(4, len(logs))
s.Equal(8, len(logs))
domains := []string{}
for _, l := range logs {
domains = append(domains, l.DNSRequest.Domain)
}
s.Equal([]string{"example.com", "example.org", "old.example.com", "sub.example.com"}, domains)
s.Equal([]string{"dnssec.unanswered.test", "example.com", "example.org", "legacy.unanswered.test", "old.example.com", "sub.example.com", "timeout.unanswered.test", "unavailable.example.net"}, domains)
})

s.Run("client ip ascending", func() {
logs, err := s.service.GetProfileQueryLogs(ctx, s.profileID, retention, "all", "LAST_7_DAYS", "", "", "client_ip", 0, 0)
s.Require().NoError(err)
s.Equal(4, len(logs))
s.Equal(8, len(logs))
ips := []string{}
for _, l := range logs {
ips = append(ips, l.ClientIP)
}
s.Equal([]string{"1.2.3.4", "1.2.3.5", "1.2.3.6", "1.2.3.7"}, ips)
s.Equal([]string{"1.2.3.4", "1.2.3.5", "1.2.3.6", "1.2.3.7", "1.2.3.8", "1.2.3.8", "1.2.3.8", "1.2.3.8"}, ips)
})
}

Expand All @@ -272,8 +283,8 @@ func (s *QueryLogsServiceSuite) TestDownloadProfileQueryLogs() {

logs, err := s.service.DownloadProfileQueryLogs(ctx, s.profileID, retention, 0, 0)
s.Require().NoError(err)
// Should include the document outside 1d window (old.chatgpt.com) but not other profile's logs.
s.Equal(4, len(logs), "download should return all 4 logs for profile")
// Should include the document outside 1d window (old.example.com) but not other profile's logs.
s.Equal(8, len(logs), "download should return all 8 logs for profile")
foundOld := false
for _, l := range logs {
if l.DNSRequest.Domain == "old.example.com" {
Expand Down
4 changes: 3 additions & 1 deletion app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,9 @@ async function profilesOnlyLoader() {

// Unified base layout for public/protected wrappers
function BaseLayout({ children, mode }: { children: React.ReactNode, mode: 'public' | 'app' }) {
const baseClasses = 'relative flex flex-col min-h-screen overflow-x-hidden bg-[var(--shadcn-ui-app-background)]';
// overflow-x-clip, not -hidden: `hidden` computes overflow-y:auto and turns the
// wrapper into a scroll container nested inside the viewport scroller.
const baseClasses = 'relative flex flex-col min-h-screen overflow-x-clip bg-[var(--shadcn-ui-app-background)]';
if (mode === 'public') {
return (
<div data-testid="public-layout" className={baseClasses + ' w-full'} style={{ width: '100vw', maxWidth: '100vw' }}>
Expand Down
Loading
Loading