diff --git a/api/api/errors.go b/api/api/errors.go index f4f5b48a..ce82a5d2 100644 --- a/api/api/errors.go +++ b/api/api/errors.go @@ -41,6 +41,7 @@ var ( ErrFailedToGetSubscription = errors.New("failed to get subscription data") ErrFailedToUpdateSubscription = errors.New("failed to update subscription") ErrFailedToGetQueryLogs = errors.New("failed to get profile query logs") + ErrFailedToGetQueryLogDevices = errors.New("failed to get profile query log devices") ErrFailedToGetStatistics = errors.New("failed to get profile statistics") ErrFailedToDeleteQueryLogs = errors.New("failed to delete profile query logs") ErrFailedToGetAccount = errors.New("failed to get account data") diff --git a/api/api/query_logs.go b/api/api/query_logs.go index ad1a5ef2..f79a7b4d 100644 --- a/api/api/query_logs.go +++ b/api/api/query_logs.go @@ -58,6 +58,32 @@ func (s *APIServer) getProfileQueryLogs() fiber.Handler { return handler } +// @Summary Get profile query log devices +// @Description List distinct device IDs seen in the profile's query logs (current retention window), each with its last-seen timestamp, sorted by device ID +// @Tags QueryLogs +// @Produce json +// @Security ApiKeyAuth +// @Param id path string true "Profile ID" +// @Success 200 {object} []model.QueryLogDevice +// @Failure 404 {object} ErrResponse +// @Failure 429 {object} ErrResponse +// @Failure 500 {object} ErrResponse +// @Router /api/v1/profiles/{id}/logs/devices [get] +func (s *APIServer) getProfileQueryLogDevices() fiber.Handler { + handler := func(c *fiber.Ctx) error { + profileId := c.Params("id") + accountId := auth.GetAccountID(c) + devices, err := s.Service.GetProfileQueryLogDevices(c.UserContext(), accountId, profileId) + if err != nil { + log.Ctx(c.UserContext()).Error().Err(err).Msg(ErrFailedToGetQueryLogDevices.Error()) + return HandleError(c, err, ErrFailedToGetQueryLogDevices.Error()) + } + + return c.Status(200).JSON(devices) + } + return handler +} + // @Summary Download profile query logs // @Description Download profile query logs // @Tags QueryLogs diff --git a/api/api/server.go b/api/api/server.go index 197de7d1..9db5d7a6 100644 --- a/api/api/server.go +++ b/api/api/server.go @@ -258,6 +258,7 @@ func (s *APIServer) RegisterRoutes() { // Query logs endpoints profiles.Get("/:id/logs", middleware.NewLimit(500, 1*time.Minute), s.getProfileQueryLogs()) + profiles.Get("/:id/logs/devices", middleware.NewLimit(20, 1*time.Minute), s.getProfileQueryLogDevices()) profiles.Get("/:id/logs/download", middleware.NewLimit(20, 1*time.Minute), s.downloadProfileQueryLogs()) profiles.Delete("/:id/logs", middleware.NewLimit(20, 1*time.Minute), s.deleteProfileQueryLogs()) diff --git a/api/db/mongodb/migrations/README.md b/api/db/mongodb/migrations/README.md index a2c9a612..01c3fc08 100644 --- a/api/db/mongodb/migrations/README.md +++ b/api/db/mongodb/migrations/README.md @@ -7,4 +7,4 @@ https://pkg.go.dev/github.com/golang-migrate/migrate/v4/database/mongodb#section ### Query logs collections -Note: Query logs time series collections and their indexes creation is currently handled by proxy service. +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). diff --git a/api/db/mongodb/query_logs.go b/api/db/mongodb/query_logs.go index 568aacdf..32f7dc21 100644 --- a/api/db/mongodb/query_logs.go +++ b/api/db/mongodb/query_logs.go @@ -151,6 +151,53 @@ func (r *QueryLogsRepository) GetQueryLogs(ctx context.Context, profileId string return results, nil } +// GetQueryLogDevices returns the distinct non-empty device IDs present in the +// profile's query logs (current retention collection, whole window — the TTL +// already bounds it), each with its most recent timestamp, sorted by device ID. +// Aggregation, not Collection.Distinct(): the distinct command is unsupported +// on time-series collections. +func (r *QueryLogsRepository) GetQueryLogDevices(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error) { + start := time.Now() + coll := r.getCollObject(retention) + + pipeline := mongo.Pipeline{ + bson.D{primitive.E{Key: "$match", Value: bson.D{ + primitive.E{Key: "profile_id", Value: profileId}, + // $nin (not $ne "") also excludes docs where the field is missing. + primitive.E{Key: "device_id", Value: bson.D{{Key: "$nin", Value: bson.A{"", nil}}}}, + }}}, + bson.D{primitive.E{Key: "$group", Value: bson.D{ + primitive.E{Key: "_id", Value: "$device_id"}, + primitive.E{Key: "last_seen", Value: bson.D{{Key: "$max", Value: "$timestamp"}}}, + }}}, + bson.D{primitive.E{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}}, + // Safety valve against pathological device counts; ids are ≤36 chars. + bson.D{primitive.E{Key: "$limit", Value: 500}}, + } + + cursor, err := coll.Aggregate(ctx, pipeline) + if err != nil { + return nil, err + } + + results := make([]model.QueryLogDevice, 0) + if err = cursor.All(ctx, &results); err != nil { + return nil, err + } + duration := time.Since(start) + if duration > slowQueryThreshold { + // Counts/durations only — device IDs are sensitive log keys. + log.Ctx(ctx).Warn(). + Bool("slow", true). + Str("retention", string(retention)). + Int("result_count", len(results)). + Dur("duration", duration). + Msg("Query log devices fetch took too long") + } + + return results, nil +} + func buildSortSpec(sortBy string) bson.D { switch sortBy { case "domain": diff --git a/api/db/repository/query_logs.go b/api/db/repository/query_logs.go index 7dee90ce..cf969d2b 100644 --- a/api/db/repository/query_logs.go +++ b/api/db/repository/query_logs.go @@ -8,5 +8,6 @@ import ( type QueryLogsRepository interface { GetQueryLogs(ctx context.Context, profileId string, retention model.Retention, status string, timespan int, deviceId, search, sortBy string, page, limit int) ([]model.QueryLog, error) + GetQueryLogDevices(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error) DeleteQueryLogs(ctx context.Context, profileId string) error } diff --git a/api/docs/docs.go b/api/docs/docs.go index 10c79157..34546f3a 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -1891,6 +1891,61 @@ const docTemplate = `{ } } }, + "/api/v1/profiles/{id}/logs/devices": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "List distinct device IDs seen in the profile's query logs (current retention window), each with its last-seen timestamp, sorted by device ID", + "produces": [ + "application/json" + ], + "tags": [ + "QueryLogs" + ], + "summary": "Get profile query log devices", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.QueryLogDevice" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/profiles/{id}/logs/download": { "get": { "security": [ @@ -3817,6 +3872,17 @@ const docTemplate = `{ } } }, + "model.QueryLogDevice": { + "type": "object", + "properties": { + "device_id": { + "type": "string" + }, + "last_seen": { + "type": "string" + } + } + }, "model.RebindingProtection": { "type": "object", "properties": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 8533b0a0..30116cc5 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -1883,6 +1883,61 @@ } } }, + "/api/v1/profiles/{id}/logs/devices": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "List distinct device IDs seen in the profile's query logs (current retention window), each with its last-seen timestamp, sorted by device ID", + "produces": [ + "application/json" + ], + "tags": [ + "QueryLogs" + ], + "summary": "Get profile query log devices", + "parameters": [ + { + "type": "string", + "description": "Profile ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.QueryLogDevice" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrResponse" + } + } + } + } + }, "/api/v1/profiles/{id}/logs/download": { "get": { "security": [ @@ -3809,6 +3864,17 @@ } } }, + "model.QueryLogDevice": { + "type": "object", + "properties": { + "device_id": { + "type": "string" + }, + "last_seen": { + "type": "string" + } + } + }, "model.RebindingProtection": { "type": "object", "properties": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 0bc5a788..bbba5650 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -649,6 +649,13 @@ definitions: timestamp: type: string type: object + model.QueryLogDevice: + properties: + device_id: + type: string + last_seen: + type: string + type: object model.RebindingProtection: properties: enabled: @@ -2608,6 +2615,42 @@ paths: summary: Get profile query logs tags: - QueryLogs + /api/v1/profiles/{id}/logs/devices: + get: + description: List distinct device IDs seen in the profile's query logs (current + retention window), each with its last-seen timestamp, sorted by device ID + parameters: + - description: Profile ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.QueryLogDevice' + type: array + "404": + description: Not Found + schema: + $ref: '#/definitions/api.ErrResponse' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/api.ErrResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrResponse' + security: + - ApiKeyAuth: [] + summary: Get profile query log devices + tags: + - QueryLogs /api/v1/profiles/{id}/logs/download: get: description: Download profile query logs diff --git a/api/internal/middleware/subscription_test.go b/api/internal/middleware/subscription_test.go index b3441d75..4ce21fe2 100644 --- a/api/internal/middleware/subscription_test.go +++ b/api/internal/middleware/subscription_test.go @@ -70,6 +70,7 @@ func TestIsLimitedAccessAllowed(t *testing.T) { {"GET", "/api/v1/profiles", true}, {"GET", "/api/v1/profiles/abc123", true}, {"GET", "/api/v1/profiles/abc123/logs", true}, + {"GET", "/api/v1/profiles/abc123/logs/devices", true}, {"GET", "/api/v1/profiles/abc123/logs/download", true}, {"DELETE", "/api/v1/profiles/abc123/logs", true}, {"GET", "/api/v1/profiles/abc123/statistics", true}, diff --git a/api/mocks/db.go b/api/mocks/db.go index 23d651a4..e591336f 100644 --- a/api/mocks/db.go +++ b/api/mocks/db.go @@ -2638,6 +2638,80 @@ func (_c *Db_GetProfilesByAccountId_Call) RunAndReturn(run func(ctx context.Cont return _c } +// GetQueryLogDevices provides a mock function for the type Db +func (_mock *Db) GetQueryLogDevices(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error) { + ret := _mock.Called(ctx, profileId, retention) + + if len(ret) == 0 { + panic("no return value specified for GetQueryLogDevices") + } + + var r0 []model.QueryLogDevice + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, model.Retention) ([]model.QueryLogDevice, error)); ok { + return returnFunc(ctx, profileId, retention) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, model.Retention) []model.QueryLogDevice); ok { + r0 = returnFunc(ctx, profileId, retention) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.QueryLogDevice) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, model.Retention) error); ok { + r1 = returnFunc(ctx, profileId, retention) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Db_GetQueryLogDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetQueryLogDevices' +type Db_GetQueryLogDevices_Call struct { + *mock.Call +} + +// GetQueryLogDevices is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - retention model.Retention +func (_e *Db_Expecter) GetQueryLogDevices(ctx interface{}, profileId interface{}, retention interface{}) *Db_GetQueryLogDevices_Call { + return &Db_GetQueryLogDevices_Call{Call: _e.mock.On("GetQueryLogDevices", ctx, profileId, retention)} +} + +func (_c *Db_GetQueryLogDevices_Call) Run(run func(ctx context.Context, profileId string, retention model.Retention)) *Db_GetQueryLogDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 model.Retention + if args[2] != nil { + arg2 = args[2].(model.Retention) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *Db_GetQueryLogDevices_Call) Return(queryLogDevices []model.QueryLogDevice, err error) *Db_GetQueryLogDevices_Call { + _c.Call.Return(queryLogDevices, err) + return _c +} + +func (_c *Db_GetQueryLogDevices_Call) RunAndReturn(run func(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error)) *Db_GetQueryLogDevices_Call { + _c.Call.Return(run) + return _c +} + // GetQueryLogs provides a mock function for the type Db func (_mock *Db) GetQueryLogs(ctx context.Context, profileId string, retention model.Retention, status string, timespan int, deviceId string, search string, sortBy string, page int, limit int) ([]model.QueryLog, error) { ret := _mock.Called(ctx, profileId, retention, status, timespan, deviceId, search, sortBy, page, limit) diff --git a/api/mocks/profile_servicer.go b/api/mocks/profile_servicer.go index 04a246d9..6ee71050 100644 --- a/api/mocks/profile_servicer.go +++ b/api/mocks/profile_servicer.go @@ -1078,6 +1078,80 @@ func (_c *ProfileServicer_GetProfile_Call) RunAndReturn(run func(ctx context.Con return _c } +// GetProfileQueryLogDevices provides a mock function for the type ProfileServicer +func (_mock *ProfileServicer) GetProfileQueryLogDevices(ctx context.Context, accountId string, profileId string) ([]model.QueryLogDevice, error) { + ret := _mock.Called(ctx, accountId, profileId) + + if len(ret) == 0 { + panic("no return value specified for GetProfileQueryLogDevices") + } + + var r0 []model.QueryLogDevice + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]model.QueryLogDevice, error)); ok { + return returnFunc(ctx, accountId, profileId) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []model.QueryLogDevice); ok { + r0 = returnFunc(ctx, accountId, profileId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.QueryLogDevice) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, accountId, profileId) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ProfileServicer_GetProfileQueryLogDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProfileQueryLogDevices' +type ProfileServicer_GetProfileQueryLogDevices_Call struct { + *mock.Call +} + +// GetProfileQueryLogDevices is a helper method to define mock.On call +// - ctx context.Context +// - accountId string +// - profileId string +func (_e *ProfileServicer_Expecter) GetProfileQueryLogDevices(ctx interface{}, accountId interface{}, profileId interface{}) *ProfileServicer_GetProfileQueryLogDevices_Call { + return &ProfileServicer_GetProfileQueryLogDevices_Call{Call: _e.mock.On("GetProfileQueryLogDevices", ctx, accountId, profileId)} +} + +func (_c *ProfileServicer_GetProfileQueryLogDevices_Call) Run(run func(ctx context.Context, accountId string, profileId string)) *ProfileServicer_GetProfileQueryLogDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ProfileServicer_GetProfileQueryLogDevices_Call) Return(queryLogDevices []model.QueryLogDevice, err error) *ProfileServicer_GetProfileQueryLogDevices_Call { + _c.Call.Return(queryLogDevices, err) + return _c +} + +func (_c *ProfileServicer_GetProfileQueryLogDevices_Call) RunAndReturn(run func(ctx context.Context, accountId string, profileId string) ([]model.QueryLogDevice, error)) *ProfileServicer_GetProfileQueryLogDevices_Call { + _c.Call.Return(run) + return _c +} + // GetProfileQueryLogs provides a mock function for the type ProfileServicer func (_mock *ProfileServicer) GetProfileQueryLogs(ctx context.Context, accountId string, profileId string, status string, timespan string, deviceId string, search string, sortBy string, page int, limit int) ([]model.QueryLog, error) { ret := _mock.Called(ctx, accountId, profileId, status, timespan, deviceId, search, sortBy, page, limit) diff --git a/api/mocks/query_logs_repository.go b/api/mocks/query_logs_repository.go index dd98f17b..61b84f1c 100644 --- a/api/mocks/query_logs_repository.go +++ b/api/mocks/query_logs_repository.go @@ -95,6 +95,80 @@ func (_c *QueryLogsRepository_DeleteQueryLogs_Call) RunAndReturn(run func(ctx co return _c } +// GetQueryLogDevices provides a mock function for the type QueryLogsRepository +func (_mock *QueryLogsRepository) GetQueryLogDevices(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error) { + ret := _mock.Called(ctx, profileId, retention) + + if len(ret) == 0 { + panic("no return value specified for GetQueryLogDevices") + } + + var r0 []model.QueryLogDevice + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, model.Retention) ([]model.QueryLogDevice, error)); ok { + return returnFunc(ctx, profileId, retention) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, model.Retention) []model.QueryLogDevice); ok { + r0 = returnFunc(ctx, profileId, retention) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.QueryLogDevice) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, model.Retention) error); ok { + r1 = returnFunc(ctx, profileId, retention) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// QueryLogsRepository_GetQueryLogDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetQueryLogDevices' +type QueryLogsRepository_GetQueryLogDevices_Call struct { + *mock.Call +} + +// GetQueryLogDevices is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - retention model.Retention +func (_e *QueryLogsRepository_Expecter) GetQueryLogDevices(ctx interface{}, profileId interface{}, retention interface{}) *QueryLogsRepository_GetQueryLogDevices_Call { + return &QueryLogsRepository_GetQueryLogDevices_Call{Call: _e.mock.On("GetQueryLogDevices", ctx, profileId, retention)} +} + +func (_c *QueryLogsRepository_GetQueryLogDevices_Call) Run(run func(ctx context.Context, profileId string, retention model.Retention)) *QueryLogsRepository_GetQueryLogDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 model.Retention + if args[2] != nil { + arg2 = args[2].(model.Retention) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *QueryLogsRepository_GetQueryLogDevices_Call) Return(queryLogDevices []model.QueryLogDevice, err error) *QueryLogsRepository_GetQueryLogDevices_Call { + _c.Call.Return(queryLogDevices, err) + return _c +} + +func (_c *QueryLogsRepository_GetQueryLogDevices_Call) RunAndReturn(run func(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error)) *QueryLogsRepository_GetQueryLogDevices_Call { + _c.Call.Return(run) + return _c +} + // GetQueryLogs provides a mock function for the type QueryLogsRepository func (_mock *QueryLogsRepository) GetQueryLogs(ctx context.Context, profileId string, retention model.Retention, status string, timespan int, deviceId string, search string, sortBy string, page int, limit int) ([]model.QueryLog, error) { ret := _mock.Called(ctx, profileId, retention, status, timespan, deviceId, search, sortBy, page, limit) diff --git a/api/mocks/query_logs_servicer.go b/api/mocks/query_logs_servicer.go index 5a6065c8..2cf10f2c 100644 --- a/api/mocks/query_logs_servicer.go +++ b/api/mocks/query_logs_servicer.go @@ -181,6 +181,80 @@ func (_c *QueryLogsServicer_DownloadProfileQueryLogs_Call) RunAndReturn(run func return _c } +// GetProfileQueryLogDevices provides a mock function for the type QueryLogsServicer +func (_mock *QueryLogsServicer) GetProfileQueryLogDevices(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error) { + ret := _mock.Called(ctx, profileId, retention) + + if len(ret) == 0 { + panic("no return value specified for GetProfileQueryLogDevices") + } + + var r0 []model.QueryLogDevice + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, model.Retention) ([]model.QueryLogDevice, error)); ok { + return returnFunc(ctx, profileId, retention) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, model.Retention) []model.QueryLogDevice); ok { + r0 = returnFunc(ctx, profileId, retention) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.QueryLogDevice) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, model.Retention) error); ok { + r1 = returnFunc(ctx, profileId, retention) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// QueryLogsServicer_GetProfileQueryLogDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProfileQueryLogDevices' +type QueryLogsServicer_GetProfileQueryLogDevices_Call struct { + *mock.Call +} + +// GetProfileQueryLogDevices is a helper method to define mock.On call +// - ctx context.Context +// - profileId string +// - retention model.Retention +func (_e *QueryLogsServicer_Expecter) GetProfileQueryLogDevices(ctx interface{}, profileId interface{}, retention interface{}) *QueryLogsServicer_GetProfileQueryLogDevices_Call { + return &QueryLogsServicer_GetProfileQueryLogDevices_Call{Call: _e.mock.On("GetProfileQueryLogDevices", ctx, profileId, retention)} +} + +func (_c *QueryLogsServicer_GetProfileQueryLogDevices_Call) Run(run func(ctx context.Context, profileId string, retention model.Retention)) *QueryLogsServicer_GetProfileQueryLogDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 model.Retention + if args[2] != nil { + arg2 = args[2].(model.Retention) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *QueryLogsServicer_GetProfileQueryLogDevices_Call) Return(queryLogDevices []model.QueryLogDevice, err error) *QueryLogsServicer_GetProfileQueryLogDevices_Call { + _c.Call.Return(queryLogDevices, err) + return _c +} + +func (_c *QueryLogsServicer_GetProfileQueryLogDevices_Call) RunAndReturn(run func(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error)) *QueryLogsServicer_GetProfileQueryLogDevices_Call { + _c.Call.Return(run) + return _c +} + // GetProfileQueryLogs provides a mock function for the type QueryLogsServicer func (_mock *QueryLogsServicer) GetProfileQueryLogs(ctx context.Context, profileId string, retention model.Retention, status string, timespan string, deviceId string, search string, sortBy string, page int, limit int) ([]model.QueryLog, error) { ret := _mock.Called(ctx, profileId, retention, status, timespan, deviceId, search, sortBy, page, limit) diff --git a/api/mocks/servicer.go b/api/mocks/servicer.go index 60bf1051..350d9b4d 100644 --- a/api/mocks/servicer.go +++ b/api/mocks/servicer.go @@ -2741,6 +2741,80 @@ func (_c *Servicer_GetProfile_Call) RunAndReturn(run func(ctx context.Context, a return _c } +// GetProfileQueryLogDevices provides a mock function for the type Servicer +func (_mock *Servicer) GetProfileQueryLogDevices(ctx context.Context, accountId string, profileId string) ([]model.QueryLogDevice, error) { + ret := _mock.Called(ctx, accountId, profileId) + + if len(ret) == 0 { + panic("no return value specified for GetProfileQueryLogDevices") + } + + var r0 []model.QueryLogDevice + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]model.QueryLogDevice, error)); ok { + return returnFunc(ctx, accountId, profileId) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []model.QueryLogDevice); ok { + r0 = returnFunc(ctx, accountId, profileId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.QueryLogDevice) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, accountId, profileId) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Servicer_GetProfileQueryLogDevices_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProfileQueryLogDevices' +type Servicer_GetProfileQueryLogDevices_Call struct { + *mock.Call +} + +// GetProfileQueryLogDevices is a helper method to define mock.On call +// - ctx context.Context +// - accountId string +// - profileId string +func (_e *Servicer_Expecter) GetProfileQueryLogDevices(ctx interface{}, accountId interface{}, profileId interface{}) *Servicer_GetProfileQueryLogDevices_Call { + return &Servicer_GetProfileQueryLogDevices_Call{Call: _e.mock.On("GetProfileQueryLogDevices", ctx, accountId, profileId)} +} + +func (_c *Servicer_GetProfileQueryLogDevices_Call) Run(run func(ctx context.Context, accountId string, profileId string)) *Servicer_GetProfileQueryLogDevices_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *Servicer_GetProfileQueryLogDevices_Call) Return(queryLogDevices []model.QueryLogDevice, err error) *Servicer_GetProfileQueryLogDevices_Call { + _c.Call.Return(queryLogDevices, err) + return _c +} + +func (_c *Servicer_GetProfileQueryLogDevices_Call) RunAndReturn(run func(ctx context.Context, accountId string, profileId string) ([]model.QueryLogDevice, error)) *Servicer_GetProfileQueryLogDevices_Call { + _c.Call.Return(run) + return _c +} + // GetProfileQueryLogs provides a mock function for the type Servicer func (_mock *Servicer) GetProfileQueryLogs(ctx context.Context, accountId string, profileId string, status string, timespan string, deviceId string, search string, sortBy string, page int, limit int) ([]model.QueryLog, error) { ret := _mock.Called(ctx, accountId, profileId, status, timespan, deviceId, search, sortBy, page, limit) diff --git a/api/model/query_log.go b/api/model/query_log.go index 1f7caba0..2ef7a27e 100644 --- a/api/model/query_log.go +++ b/api/model/query_log.go @@ -47,3 +47,12 @@ type DNSRequest struct { ResponseCode string `json:"response_code" bson:"response_code"` DNSSEC bool `json:"dnssec" bson:"dnssec"` } + +// QueryLogDevice is one distinct device seen in a profile's query logs within +// the current retention window. DeviceId is the user-authored device name from +// the DNS stamp / DoH path (libs/deviceid, max 36 chars). The bson "_id" tag +// decodes the $group aggregation output directly. +type QueryLogDevice struct { + DeviceId string `json:"device_id" bson:"_id"` + LastSeen time.Time `json:"last_seen" bson:"last_seen"` +} diff --git a/api/service/profile/service.go b/api/service/profile/service.go index f2ffdd51..d0d82c7d 100644 --- a/api/service/profile/service.go +++ b/api/service/profile/service.go @@ -2,6 +2,7 @@ package profile import ( "context" + "encoding/json" "errors" "slices" "strings" @@ -39,6 +40,11 @@ type ServicesCatalogReader interface { const queryLogsRateLimitMax = 120 const queryLogsRateLimitWindow = time.Minute +// Cache-aside for the distinct-device aggregation (unindexable full-window +// bucket unpack — see api-endpoint-behaviour #J5). +const queryLogDevicesCachePrefix = "query_log_devices:" +const queryLogDevicesCacheTTL = 10 * time.Minute + type ProfileService struct { ProfileRepository repository.ProfileRepository AccountRepository repository.AccountRepository @@ -215,6 +221,44 @@ func (p *ProfileService) GetProfileQueryLogs(ctx context.Context, accountId, pro return p.QueryLogsService.GetProfileQueryLogs(ctx, profileId, profile.Settings.Logs.Retention, status, timespan, deviceId, search, sortBy, page, limit) } +// GetProfileQueryLogDevices returns the distinct device IDs seen in the +// profile's query logs (current retention window). Cache-aside with a short +// TTL: the aggregation must unpack every bucket of the profile's window +// (device_id is a measurement field — no index can serve the $group), which +// costs ~1.4s at 1M docs. Staleness is masked client-side: the frontend +// unions this list with device ids observed in fetched rows. +func (p *ProfileService) GetProfileQueryLogDevices(ctx context.Context, accountId, profileId string) ([]model.QueryLogDevice, error) { + profile, err := p.validateProfileIdAffiliation(ctx, accountId, profileId) + if err != nil { + return nil, err + } + + cacheKey := queryLogDevicesCachePrefix + profileId + if raw, cacheErr := p.Cache.Get(ctx, cacheKey); cacheErr == nil && raw != "" { + var cached []model.QueryLogDevice + if jsonErr := json.Unmarshal([]byte(raw), &cached); jsonErr == nil { + return cached, nil + } + } + + devices, err := p.QueryLogsService.GetProfileQueryLogDevices(ctx, profileId, profile.Settings.Logs.Retention) + if err != nil { + return nil, err + } + // Never cache an empty list: a fresh profile queried before the collector's + // first flush would otherwise pin "no devices" for the whole TTL. Empty-window + // aggregations are cheap — there are no buckets to unpack. + if len(devices) > 0 { + if raw, jsonErr := json.Marshal(devices); jsonErr == nil { + if cacheErr := p.Cache.Set(ctx, cacheKey, raw, queryLogDevicesCacheTTL); cacheErr != nil { + log.Ctx(ctx).Warn().Err(cacheErr).Msg("failed to cache query log devices") + } + } + } + + return devices, nil +} + // DownloadProfileQueryLogs returns all existing profile DNS query logs func (p *ProfileService) DownloadProfileQueryLogs(ctx context.Context, accountId, profileId string, page, limit int) ([]model.QueryLog, error) { profile, err := p.validateProfileIdAffiliation(ctx, accountId, profileId) @@ -258,7 +302,17 @@ func (p *ProfileService) DeleteProfileQueryLogs(ctx context.Context, accountId, return err } - return p.QueryLogsService.DeleteProfileQueryLogs(ctx, profileId) + if err := p.QueryLogsService.DeleteProfileQueryLogs(ctx, profileId); err != nil { + return err + } + + // Deleting logs deletes the device list's source — drop the cached copy so + // it cannot outlive the data (best-effort; TTL bounds a miss). + if cacheErr := p.Cache.Del(ctx, queryLogDevicesCachePrefix+profileId); cacheErr != nil { + log.Ctx(ctx).Warn().Err(cacheErr).Msg("failed to invalidate query log devices cache") + } + + return nil } // UpdateProfile updates profile data diff --git a/api/service/profile/service_test.go b/api/service/profile/service_test.go index 253eaa9d..2141f9b8 100644 --- a/api/service/profile/service_test.go +++ b/api/service/profile/service_test.go @@ -2133,6 +2133,7 @@ func (suite *ProfileTestSuite) TestDeleteProfileQueryLogs() { // Reset mock expectations for each test case suite.mockProfileRepo.ExpectedCalls = nil suite.mockQueryLogsRepo.ExpectedCalls = nil + suite.mockCache.ExpectedCalls = nil if tt.repoError != nil { suite.mockProfileRepo.On("GetProfileById", context.Background(), tt.profileID).Return(nil, tt.repoError) @@ -2144,6 +2145,8 @@ func (suite *ProfileTestSuite) TestDeleteProfileQueryLogs() { suite.mockQueryLogsRepo.On("DeleteQueryLogs", context.Background(), tt.profileID).Return(tt.deleteError) } else { suite.mockQueryLogsRepo.On("DeleteQueryLogs", context.Background(), tt.profileID).Return(nil) + // Deleting logs invalidates the cached device list (best-effort). + suite.mockCache.On("Del", context.Background(), "query_log_devices:"+tt.profileID).Return(nil) } } } @@ -2162,6 +2165,104 @@ func (suite *ProfileTestSuite) TestDeleteProfileQueryLogs() { } } +// TestGetProfileQueryLogDevices verifies the cache-aside around the distinct- +// device aggregation: miss → repo + cache write; hit → no repo call; repo +// error → no cache write. tableRef: api-endpoint-behaviour #J5 +func (suite *ProfileTestSuite) TestGetProfileQueryLogDevices() { + ctx := context.Background() + owned := &model.Profile{ + ProfileId: "profile123", + AccountId: "account123", + Name: "Test Profile", + Settings: &model.ProfileSettings{Logs: &model.LogsSettings{Retention: model.RetentionOneWeek}}, + } + cacheKey := "query_log_devices:profile123" + repoDevices := []model.QueryLogDevice{{DeviceId: "laptop"}, {DeviceId: "phone"}} + + suite.Run("cache miss aggregates and stores", func() { + suite.mockProfileRepo.ExpectedCalls = nil + suite.mockQueryLogsRepo.ExpectedCalls = nil + suite.mockCache.ExpectedCalls = nil + suite.mockQueryLogsRepo.Calls = nil + suite.mockCache.Calls = nil + + suite.mockProfileRepo.On("GetProfileById", ctx, "profile123").Return(owned, nil) + suite.mockCache.On("Get", ctx, cacheKey).Return("", errors.New("redis: nil")) + // Retention must be forwarded from the profile settings. + suite.mockQueryLogsRepo.On("GetQueryLogDevices", ctx, "profile123", model.RetentionOneWeek).Return(repoDevices, nil) + suite.mockCache.On("Set", ctx, cacheKey, mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil) + + devices, err := suite.service.GetProfileQueryLogDevices(ctx, "account123", "profile123") + suite.NoError(err) + suite.Equal(repoDevices, devices) + }) + + suite.Run("cache hit skips the aggregation", func() { + suite.mockProfileRepo.ExpectedCalls = nil + suite.mockQueryLogsRepo.ExpectedCalls = nil + suite.mockCache.ExpectedCalls = nil + suite.mockQueryLogsRepo.Calls = nil + suite.mockCache.Calls = nil + + suite.mockProfileRepo.On("GetProfileById", ctx, "profile123").Return(owned, nil) + suite.mockCache.On("Get", ctx, cacheKey).Return(`[{"device_id":"laptop","last_seen":"0001-01-01T00:00:00Z"}]`, nil) + + devices, err := suite.service.GetProfileQueryLogDevices(ctx, "account123", "profile123") + suite.NoError(err) + suite.Len(devices, 1) + suite.Equal("laptop", devices[0].DeviceId) + suite.mockQueryLogsRepo.AssertNotCalled(suite.T(), "GetQueryLogDevices", mock.Anything, mock.Anything, mock.Anything) + }) + + suite.Run("empty result is returned but never cached", func() { + suite.mockProfileRepo.ExpectedCalls = nil + suite.mockQueryLogsRepo.ExpectedCalls = nil + suite.mockCache.ExpectedCalls = nil + suite.mockQueryLogsRepo.Calls = nil + suite.mockCache.Calls = nil + + suite.mockProfileRepo.On("GetProfileById", ctx, "profile123").Return(owned, nil) + suite.mockCache.On("Get", ctx, cacheKey).Return("", errors.New("redis: nil")) + suite.mockQueryLogsRepo.On("GetQueryLogDevices", ctx, "profile123", model.RetentionOneWeek).Return([]model.QueryLogDevice{}, nil) + + devices, err := suite.service.GetProfileQueryLogDevices(ctx, "account123", "profile123") + suite.NoError(err) + suite.Empty(devices) + // Caching [] would pin "no devices" for the TTL on fresh profiles whose + // first rows are still in the collector batch. + suite.mockCache.AssertNotCalled(suite.T(), "Set", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + + suite.Run("aggregation error is not cached", func() { + suite.mockProfileRepo.ExpectedCalls = nil + suite.mockQueryLogsRepo.ExpectedCalls = nil + suite.mockCache.ExpectedCalls = nil + suite.mockQueryLogsRepo.Calls = nil + suite.mockCache.Calls = nil + + suite.mockProfileRepo.On("GetProfileById", ctx, "profile123").Return(owned, nil) + suite.mockCache.On("Get", ctx, cacheKey).Return("", errors.New("redis: nil")) + suite.mockQueryLogsRepo.On("GetQueryLogDevices", ctx, "profile123", model.RetentionOneWeek).Return(nil, errors.New("aggregation failed")) + + _, err := suite.service.GetProfileQueryLogDevices(ctx, "account123", "profile123") + suite.Error(err) + suite.mockCache.AssertNotCalled(suite.T(), "Set", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + + suite.Run("foreign profile is not found", func() { + suite.mockProfileRepo.ExpectedCalls = nil + suite.mockQueryLogsRepo.ExpectedCalls = nil + suite.mockCache.ExpectedCalls = nil + suite.mockQueryLogsRepo.Calls = nil + suite.mockCache.Calls = nil + + suite.mockProfileRepo.On("GetProfileById", ctx, "profile123").Return(owned, nil) + + _, err := suite.service.GetProfileQueryLogDevices(ctx, "other-account", "profile123") + suite.Error(err) + }) +} + // TestCreateCustomRulesBulkAutoPrepend verifies the auto-prepend "*." logic // in CreateCustomRulesBulk when custom_rules_subdomains_rule is "include". // It ensures non-FQDN inputs (IPs, ASNs, CIDRs, dot-prefixes, wildcards) diff --git a/api/service/query_logs/devices_bench_test.go b/api/service/query_logs/devices_bench_test.go new file mode 100644 index 00000000..df6b7bd9 --- /dev/null +++ b/api/service/query_logs/devices_bench_test.go @@ -0,0 +1,180 @@ +package querylogs + +// Env-gated benchmark for the device-list aggregation against a realistically +// sized 1-month retention collection. Not part of the regular suite — run with: +// +// BENCH_QUERY_LOG_DEVICES=1 go test ./service/query_logs/ -run TestBenchmarkQueryLogDevices -v +// BENCH_DOCS=3000000 BENCH_DEVICES=25 BENCH_QUERY_LOG_DEVICES=1 go test ... (overrides) +// +// Baselines measured alongside: the paged logs fetch (default sort, the hottest +// existing path) and the unindexed domain sort (the most expensive existing path) +// over the same data, so the device aggregation's cost has context. + +import ( + "context" + "fmt" + "net/url" + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/ivpn/dns/api/db/mongodb" + "github.com/ivpn/dns/api/model" +) + +func benchEnvInt(name string, def int) int { + if v, err := strconv.Atoi(os.Getenv(name)); err == nil && v > 0 { + return v + } + return def +} + +func TestBenchmarkQueryLogDevices(t *testing.T) { + if os.Getenv("BENCH_QUERY_LOG_DEVICES") != "1" { + t.Skip("set BENCH_QUERY_LOG_DEVICES=1 to run the device-list benchmark") + } + ctx := context.Background() + + // Container boot mirrors QueryLogsServiceSuite.SetupSuite. + mongoImage := firstNonEmpty(os.Getenv("TEST_MONGO_IMAGE"), "mongo:7.0.8") + username := firstNonEmpty(os.Getenv("TEST_MONGO_USERNAME"), "testuser") + password := firstNonEmpty(os.Getenv("TEST_MONGO_PASSWORD"), "testpass") + authSource := firstNonEmpty(os.Getenv("DB_AUTH_SOURCE"), "admin") + req := testcontainers.ContainerRequest{ + Image: mongoImage, + Env: map[string]string{ + "MONGO_INITDB_ROOT_USERNAME": username, + "MONGO_INITDB_ROOT_PASSWORD": password, + }, + ExposedPorts: []string{"27017/tcp"}, + WaitingFor: wait.ForLog("Waiting for connections").WithStartupTimeout(60 * time.Second), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) + require.NoError(t, err) + defer func() { _ = container.Terminate(ctx) }() + + host, err := container.Host(ctx) + require.NoError(t, err) + port, err := container.MappedPort(ctx, "27017/tcp") + require.NoError(t, err) + uri := fmt.Sprintf("mongodb://%s:%s@%s:%s", url.QueryEscape(username), url.QueryEscape(password), host, port.Port()) + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri).SetAuth(options.Credential{Username: username, Password: password, AuthSource: authSource})) + require.NoError(t, err) + + dbName := "dns_query_logs_bench" + _ = client.Database(dbName).Drop(ctx) + repo := mongodb.NewQueryLogsRepository(client, dbName, "query_logs") + service := NewQueryLogsService(&repo) + profileID := primitive.NewObjectID().Hex() + + // A REAL time-series collection with the proxy's exact options — a plain + // InsertMany would auto-create a regular collection and the numbers would + // not reflect bucket packing/unpacking at all. MongoDB also auto-creates + // the {profile_id, timestamp} meta+time index on creation (6.3+). + tsOpts := options.CreateCollection().SetTimeSeriesOptions( + options.TimeSeries(). + SetTimeField("timestamp"). + SetMetaField("profile_id"). + SetGranularity("seconds"), + ).SetExpireAfterSeconds(2592000) + require.NoError(t, client.Database(dbName).CreateCollection(ctx, "query_logs_1m", tsOpts)) + coll := client.Database(dbName).Collection("query_logs_1m") + // Mirror the hand-created domain index observed on the dev/prod-like DB + // (present on 6h/1d/1w/1m there; owner unknown — see moddns-shadow#688). + _, err = coll.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{ + {Key: "profile_id", Value: 1}, + {Key: "dns_request.domain", Value: 1}, + {Key: "timestamp", Value: -1}, + }, + Options: options.Index().SetName("profile_domain_timestamp"), + }) + require.NoError(t, err) + + docCount := benchEnvInt("BENCH_DOCS", 1_000_000) + deviceCount := benchEnvInt("BENCH_DEVICES", 20) + + // Seed: docs spread across 30 days, devices assigned with a skew (device-00 + // gets ~half the traffic, like a busy router), ~5% with no device id. + // Distinct ids produced: device-00 .. device- = deviceCount+1. + const batchSize = 10_000 + now := time.Now() + seedStart := time.Now() + batch := make([]any, 0, batchSize) + for i := 0; i < docCount; i++ { + device := "" + if i%20 != 0 { // 5% device-less + if i%2 == 0 { + device = "device-00" + } else { + // i/2 cycles through all residues (odd i alone hits only half). + device = fmt.Sprintf("device-%02d", 1+((i/2)%deviceCount)) + } + } + ts := now.Add(-time.Duration(i%(30*24*60)) * time.Minute) + batch = append(batch, bson.D{ + {Key: "timestamp", Value: ts}, + {Key: "profile_id", Value: profileID}, + {Key: "device_id", Value: device}, + {Key: "status", Value: "processed"}, + {Key: "reasons", Value: bson.A{}}, + {Key: "dns_request", Value: bson.D{ + {Key: "domain", Value: fmt.Sprintf("host-%d.example.com", i%5000)}, + {Key: "query_type", Value: "A"}, + {Key: "response_code", Value: "NOERROR"}, + {Key: "dnssec", Value: false}, + }}, + {Key: "client_ip", Value: "10.0.0.1"}, + {Key: "protocol", Value: "udp"}, + }) + if len(batch) == batchSize { + _, err := coll.InsertMany(ctx, batch) + require.NoError(t, err) + batch = batch[:0] + } + } + if len(batch) > 0 { + _, err := coll.InsertMany(ctx, batch) + require.NoError(t, err) + } + t.Logf("seeded %d docs (%d distinct devices, 5%% device-less) in %s", docCount, deviceCount+1, time.Since(seedStart).Round(time.Millisecond)) + + timeIt := func(name string, fn func() error) { + cold := time.Now() + require.NoError(t, fn()) + coldDur := time.Since(cold) + const warmRuns = 5 + var warmTotal time.Duration + for i := 0; i < warmRuns; i++ { + start := time.Now() + require.NoError(t, fn()) + warmTotal += time.Since(start) + } + t.Logf("%-42s cold=%8s warm-avg=%8s", name, coldDur.Round(time.Millisecond), (warmTotal / warmRuns).Round(time.Millisecond)) + } + + timeIt("GetQueryLogDevices (new endpoint)", func() error { + devices, err := service.GetProfileQueryLogDevices(ctx, profileID, model.RetentionOneMonth) + if err == nil && len(devices) != deviceCount+1 { + return fmt.Errorf("expected %d devices, got %d", deviceCount+1, len(devices)) + } + return err + }) + timeIt("GetQueryLogs page1 created (hot path)", func() error { + _, err := service.GetProfileQueryLogs(ctx, profileID, model.RetentionOneMonth, "all", "LAST_MONTH", "", "", "created", 1, 100) + return err + }) + timeIt("GetQueryLogs sort=domain (worst path)", func() error { + _, err := service.GetProfileQueryLogs(ctx, profileID, model.RetentionOneMonth, "all", "LAST_MONTH", "", "", "domain", 1, 100) + return err + }) +} diff --git a/api/service/query_logs/service.go b/api/service/query_logs/service.go index 0dc685f1..82e4fe9b 100644 --- a/api/service/query_logs/service.go +++ b/api/service/query_logs/service.go @@ -44,6 +44,10 @@ func (q *QueryLogsService) DownloadProfileQueryLogs(ctx context.Context, profile return logs, nil } +func (q *QueryLogsService) GetProfileQueryLogDevices(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error) { + return q.QueryLogsRepository.GetQueryLogDevices(ctx, profileId, retention) +} + func (q *QueryLogsService) DeleteProfileQueryLogs(ctx context.Context, profileId string) error { return q.QueryLogsRepository.DeleteQueryLogs(ctx, profileId) } diff --git a/api/service/query_logs/service_test.go b/api/service/query_logs/service_test.go index 44c1f2f3..c7896e98 100644 --- a/api/service/query_logs/service_test.go +++ b/api/service/query_logs/service_test.go @@ -31,6 +31,10 @@ func (s *stubQueryLogsRepository) GetQueryLogs(ctx context.Context, profileId st return nil, nil } +func (s *stubQueryLogsRepository) GetQueryLogDevices(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error) { + return nil, nil +} + func (s *stubQueryLogsRepository) DeleteQueryLogs(ctx context.Context, profileId string) error { s.deleteCalls++ return nil @@ -281,6 +285,46 @@ func (s *QueryLogsServiceSuite) TestDownloadProfileQueryLogs() { s.True(foundOld, "expected old.example.com present in download set") } +// TestGetProfileQueryLogDevices verifies the distinct-device aggregation: +// sorted distinct ids, empty/missing device_id excluded, cross-profile +// isolation, last_seen = the device's newest timestamp, and whole-window +// scope (no timespan floor — the -25h "laptop" doc still counts). +// tableRef: api-endpoint-behaviour #J5 +func (s *QueryLogsServiceSuite) TestGetProfileQueryLogDevices() { + ctx := context.Background() + retention := model.RetentionOneWeek + + // Extra docs local to this test (SetupTest reseeds per test, so the shared + // seed's count assertions elsewhere stay untouched): empty and missing + // device_id must be excluded from the device list. + now := time.Now() + _, err := s.collMap[retention].InsertMany(ctx, []any{ + bson.D{{Key: "timestamp", Value: now.Add(-4 * time.Hour)}, {Key: "profile_id", Value: s.profileID}, {Key: "device_id", Value: ""}, {Key: "status", Value: "processed"}, {Key: "reasons", Value: bson.A{}}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "nodevice.example.com"}, {Key: "query_type", Value: "A"}, {Key: "response_code", Value: "NOERROR"}, {Key: "dnssec", Value: false}}}, {Key: "client_ip", Value: "1.2.3.8"}, {Key: "protocol", Value: "udp"}}, + bson.D{{Key: "timestamp", Value: now.Add(-5 * time.Hour)}, {Key: "profile_id", Value: s.profileID}, {Key: "status", Value: "processed"}, {Key: "reasons", Value: bson.A{}}, {Key: "dns_request", Value: bson.D{{Key: "domain", Value: "legacy.example.com"}, {Key: "query_type", Value: "A"}, {Key: "response_code", Value: "NOERROR"}, {Key: "dnssec", Value: false}}}, {Key: "client_ip", Value: "1.2.3.9"}, {Key: "protocol", Value: "udp"}}, + }) + s.Require().NoError(err) + + devices, err := s.service.GetProfileQueryLogDevices(ctx, s.profileID, retention) + s.Require().NoError(err) + + ids := make([]string, 0, len(devices)) + for _, d := range devices { + ids = append(ids, d.DeviceId) + } + // Sorted ascending; "laptop" present despite its newest doc being -2h and + // oldest -25h (whole retention window, no timespan floor); no "" or + // missing-field entries; other-profile's devices excluded. + s.Equal([]string{"laptop", "phone", "tablet"}, ids) + + // last_seen carries the newest timestamp per device. + for _, d := range devices { + if d.DeviceId == "laptop" { + s.WithinDuration(time.Now().Add(-2*time.Hour), d.LastSeen, time.Minute, "laptop last_seen should be its newest doc") + } + s.False(d.LastSeen.IsZero(), "last_seen must be set") + } +} + // TestDeleteProfileQueryLogs ensures removal from all retention collections. func (s *QueryLogsServiceSuite) TestDeleteProfileQueryLogs() { ctx := context.Background() diff --git a/api/service/service.go b/api/service/service.go index 5ac2f48a..5f4369c3 100644 --- a/api/service/service.go +++ b/api/service/service.go @@ -149,6 +149,7 @@ type ProfileServicer interface { // Query logs GetProfileQueryLogs(ctx context.Context, accountId, profileId, status, timespan, deviceId, search, sortBy string, page, limit int) ([]model.QueryLog, error) + GetProfileQueryLogDevices(ctx context.Context, accountId, profileId string) ([]model.QueryLogDevice, error) DownloadProfileQueryLogs(ctx context.Context, accountId, profileId string, page, limit int) ([]model.QueryLog, error) DeleteProfileQueryLogs(ctx context.Context, accountId, profileId string) error @@ -181,6 +182,7 @@ type ProfileServicer interface { // Note: QueryLogsServicer is not part of the Servicer interface as ProfileServicer covers its operations type QueryLogsServicer interface { GetProfileQueryLogs(ctx context.Context, profileId string, retention model.Retention, status, timespan, deviceId, search, sortBy string, page, limit int) ([]model.QueryLog, error) + GetProfileQueryLogDevices(ctx context.Context, profileId string, retention model.Retention) ([]model.QueryLogDevice, error) DownloadProfileQueryLogs(ctx context.Context, profileId string, retention model.Retention, page, limit int) ([]model.QueryLog, error) DeleteProfileQueryLogs(ctx context.Context, profileId string) error } diff --git a/app/src/App.tsx b/app/src/App.tsx index 1228b3fc..b8b88114 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -585,7 +585,10 @@ function ProtectedLayout() { // (same bug class as the old header wrapper transition, #121). // Mobile top offset comes from the sticky in-flow header; flex-1 // (BaseLayout is flex-col) replaces the old measured minHeight. - className={`bg-[var(--shadcn-ui-app-background)] w-full overflow-x-hidden box-border ${isDesktop ? 'transition-all duration-200' : 'flex-1'}`} + // overflow-x-clip, NOT -hidden: `hidden` computes overflow-y:auto and turns + // this into a scroll container, which silently breaks position:sticky in + // every page below (e.g. the logs sticky filter bar). + className={`bg-[var(--shadcn-ui-app-background)] w-full overflow-x-clip box-border ${isDesktop ? 'transition-all duration-200' : 'flex-1'}`} style={isDesktop ? { paddingTop: 'var(--app-header-stack, 64px)', marginLeft: `${sidebarWidth + shellOffset}px`, diff --git a/app/src/__tests__/e2e/logs/logs-device-filter.spec.ts b/app/src/__tests__/e2e/logs/logs-device-filter.spec.ts new file mode 100644 index 00000000..11a9c912 --- /dev/null +++ b/app/src/__tests__/e2e/logs/logs-device-filter.spec.ts @@ -0,0 +1,90 @@ +import { test, expect, type Page } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// Device filter completeness (issue item 7): the dropdown is seeded from +// GET /profiles/{id}/logs/devices (complete within retention) merged with ids +// observed in fetched rows, and selecting a device never shrinks the list. + +const profile = { id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }; + +const row = (deviceId: string, domain: string) => ({ + profile_id: 'prof1', + timestamp: '2026-08-14T10:00:00Z', + status: 'processed', + protocol: 'dns', + device_id: deviceId, + client_ip: '10.0.0.1', + dns_request: { domain, query_type: 'A' }, +}); + +const serverDevices = [ + { device_id: 'laptop', last_seen: '2026-08-14T10:00:00Z' }, + { device_id: 'phone', last_seen: '2026-08-14T09:00:00Z' }, + { device_id: 'tablet', last_seen: '2026-08-13T10:00:00Z' }, +]; + +// Routes registered AFTER registerMocks win over its defaults and catch-all +// (Playwright matches routes in reverse registration order). The logs regex is +// anchored so it cannot swallow the /logs/devices endpoint. +async function setupLogsPage(page: Page, devicesStatus = 200) { + await registerMocks(page, { authenticated: true, customProfiles: [profile] }); + await page.route(/\/api\/v1\/profiles\/prof1\/logs\/devices/i, route => { + route.fulfill({ + status: devicesStatus, + contentType: 'application/json', + body: devicesStatus === 200 ? JSON.stringify(serverDevices) : '{}', + }); + }); + const deviceParams: string[] = []; + await page.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { + const url = new URL(route.request().url()); + const deviceParam = url.searchParams.get('device_id'); + deviceParams.push(deviceParam ?? ''); + const rows = deviceParam + ? [row(deviceParam, `${deviceParam}.example.test`)] + : [row('laptop', 'one.example.test')]; + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(rows) }); + }); + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').waitFor({ state: 'attached', timeout: 10000 }); + return { deviceParams }; +} + +const deviceTrigger = (page: Page) => page.locator('[aria-label="Filter by device"]:visible'); + +test.describe('Logs device filter', () => { + test('dropdown lists all server devices and survives selecting one', async ({ page }) => { + const { deviceParams } = await setupLogsPage(page); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(1); + + // Server-seeded list: rows only ever contained "laptop", yet all three + // devices (incl. ones with no rows in the fetched window) are offered. + await deviceTrigger(page).click(); + for (const name of ['All devices', 'laptop', 'phone', 'tablet']) { + await expect(page.getByRole('option', { name })).toBeVisible(); + } + await page.getByRole('option', { name: 'phone' }).click(); + + // The logs refetch carries the device filter... + await expect.poll(() => deviceParams.includes('phone')).toBe(true); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(1); + + // ...and reopening the dropdown still shows every device — the old + // behavior collapsed it to just the selected one. + await deviceTrigger(page).click(); + for (const name of ['All devices', 'laptop', 'phone', 'tablet']) { + await expect(page.getByRole('option', { name })).toBeVisible(); + } + }); + + test('device endpoint failure degrades to row-observed ids', async ({ page }) => { + await setupLogsPage(page, 500); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(1); + + await deviceTrigger(page).click(); + await expect(page.getByRole('option', { name: 'laptop' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'phone' })).toHaveCount(0); + // No error surface — silent degrade. + await expect(page.getByTestId('logs-error')).toHaveCount(0); + }); +}); diff --git a/app/src/__tests__/e2e/logs/logs-filter-visibility.spec.ts b/app/src/__tests__/e2e/logs/logs-filter-visibility.spec.ts new file mode 100644 index 00000000..bc250e2c --- /dev/null +++ b/app/src/__tests__/e2e/logs/logs-filter-visibility.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, type Page } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// Active-filter visibility (issue item 3): accent on non-default triggers, a clear-all +// chip, and a filtered empty state that offers clearing instead of the onboarding CTA. + +const profile = { id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }; + +const rows = [ + { profile_id: 'prof1', timestamp: '2026-08-13T10:00:01Z', status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'one.example.test', query_type: 'A' } }, + { profile_id: 'prof1', timestamp: '2026-08-13T10:00:00Z', status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'two.example.test', query_type: 'A' } }, +]; + +const ACCENT = /border-\[var\(--tailwind-colors-rdns-600\)\]/; + +// Register the logs route AFTER registerMocks so it is tested BEFORE the catch-all +// route (Playwright matches routes in reverse registration order). Blocked-status +// requests return no rows so the filtered empty state renders. +async function setupLogsPage(page: Page) { + await registerMocks(page, { authenticated: true, customProfiles: [profile] }); + await page.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { + const url = new URL(route.request().url()); + const body = url.searchParams.get('status') === 'blocked' ? [] : rows; + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + }); + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').waitFor({ state: 'attached', timeout: 10000 }); +} + +const visibleByLabel = (page: Page, label: string) => + page.locator(`[aria-label="${label}"]:visible`); + +test.describe('Logs filter visibility', () => { + test('active filter accents its trigger, shows the clear chip, and the empty state clears', async ({ page }) => { + await setupLogsPage(page); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + + const statusTrigger = visibleByLabel(page, 'Filter by status'); + await expect(statusTrigger).not.toHaveClass(ACCENT); + await expect(page.getByTestId('logs-clear-filters')).toHaveCount(0); + + await statusTrigger.click(); + const blockedOption = page.getByRole('option', { name: 'Blocked' }); + // Options advertise clickability with a pointer cursor. + expect(await blockedOption.evaluate(el => getComputedStyle(el).cursor)).toBe('pointer'); + await blockedOption.click(); + + // Accent + chip appear; the blocked view is empty, so the filtered empty + // state renders with a Clear filters action (not the DNS-setup CTA). + await expect(statusTrigger).toHaveClass(ACCENT); + await expect(page.locator('[data-testid="logs-clear-filters"]:visible')).toBeVisible(); + const emptyState = page.getByTestId('logs-empty-state'); + await expect(emptyState).toBeVisible(); + await expect(emptyState.getByText(/No results for the current filters/)).toBeVisible(); + await expect(emptyState.getByRole('button', { name: /DNS Setup/ })).toHaveCount(0); + + await page.getByTestId('logs-empty-clear-filters').click(); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + await expect(statusTrigger).not.toHaveClass(ACCENT); + await expect(page.getByTestId('logs-clear-filters')).toHaveCount(0); + }); + + test('search shows a clear button and the chip only after the debounce commits', async ({ page }) => { + await setupLogsPage(page); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + + const search = page.locator('input[aria-label="Search domain or its part"]:visible'); + await search.fill('example'); + await expect(page.locator('[data-testid="logs-search-clear"]:visible')).toBeVisible(); + + // Debounce (500ms) commits the search → the clear-all chip appears. + await expect(page.locator('[data-testid="logs-clear-filters"]:visible')).toBeVisible(); + + await page.locator('[data-testid="logs-search-clear"]:visible').click(); + await expect(search).toHaveValue(''); + await expect(page.getByTestId('logs-clear-filters')).toHaveCount(0); + }); +}); diff --git a/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts b/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts index 460ccab6..e390042f 100644 --- a/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts +++ b/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts @@ -11,7 +11,7 @@ test.describe('Logs mobile layout', () => { customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }], extraRoutes: async (p) => { // Provide a deterministic set of logs with long domain to challenge layout - await p.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + await p.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { const now = new Date().toISOString(); const items = Array.from({ length: 3 }).map((_, i) => ({ profile_id: 'prof1', @@ -120,7 +120,7 @@ test.describe('Logs mobile layout', () => { dns_request: { domain: 'timed-out-query.example-longdomainforlayout-validation.test', query_type: 'A' } } ]; - await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + await page.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); }); @@ -218,7 +218,7 @@ test.describe('Logs mobile layout', () => { reasons: ['blocklist: some-blocklist-id'] } ]; - await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + await page.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); }); @@ -254,7 +254,7 @@ test.describe('Logs mobile layout', () => { { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'a.example.test' } }, { profile_id: 'prof1', timestamp: now, status: 'blocked', protocol: 'dns', device_id: 'd2', client_ip: '10.0.0.2', dns_request: { domain: 'b.example.test' } } ]; - await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + await page.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); }); @@ -289,7 +289,7 @@ test.describe('Logs mobile layout', () => { { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'dup.example.test', query_type: 'AAAA', response_code: 'NOERROR' } }, { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'other.example.test', query_type: 'A', response_code: 'NOERROR' } } ]; - await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + await page.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); }); @@ -327,7 +327,7 @@ test.describe('Logs mobile layout', () => { { profile_id: 'prof1', timestamp: now, status: 'blocked', protocol: 'dns', device_id: 'device-tablet', client_ip: '10.0.0.1', dns_request: { domain: 'a-very-long-subdomain-name.example-reallylongdomainforlayout-validation.test', query_type: 'A', response_code: 'NOERROR', dnssec: true } }, { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'device-tablet', client_ip: '10.0.0.2', dns_request: { domain: 'short.example.test', query_type: 'A', response_code: 'NOERROR' } } ]; - await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + await page.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); }); diff --git a/app/src/__tests__/e2e/logs/logs-refresh-controls.spec.ts b/app/src/__tests__/e2e/logs/logs-refresh-controls.spec.ts index 75e55073..b472a687 100644 --- a/app/src/__tests__/e2e/logs/logs-refresh-controls.spec.ts +++ b/app/src/__tests__/e2e/logs/logs-refresh-controls.spec.ts @@ -25,7 +25,7 @@ const logItem = (i: number, domain: string) => ({ async function setupLogsPage(page: Page, respond: () => object[]) { await registerMocks(page, { authenticated: true, customProfiles: [profile] }); let calls = 0; - await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + await page.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { calls++; route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(respond()) }); }); @@ -56,6 +56,11 @@ test.describe('Logs refresh controls', () => { const refresh = visibleControl(page, 'logs-refresh-button'); await expect(refresh).toHaveAccessibleName('Refresh query logs'); + // Desktop-only freshness label appears beside the controls after the first load. + if (test.info().project.name === 'chromium-desktop') { + await expect(page.getByTestId('logs-freshness')).toHaveText(/Updated (just now|\d+s ago)/); + } + await refresh.click(); await expect.poll(logsCalls).toBe(initialCalls + 1); @@ -83,6 +88,17 @@ test.describe('Logs refresh controls', () => { const intervalTrigger = visibleControl(page, 'logs-refresh-interval-trigger'); await expect(intervalTrigger).toHaveAccessibleName('Auto-refresh interval'); + // Desktop: freshness sits on its own line ABOVE the controls, so the button + // widening in live mode must not move it (the old inline placement jittered). + const isDesktop = test.info().project.name === 'chromium-desktop'; + let freshnessBefore: { x: number; y: number } | null = null; + if (isDesktop) { + const box = await page.getByTestId('logs-freshness').boundingBox(); + const triggerBox = await intervalTrigger.boundingBox(); + expect(box && triggerBox && box.y + box.height <= triggerBox.y + 1).toBe(true); + freshnessBefore = box && { x: box.x, y: box.y }; + } + // The menu offers the full interval set. await intervalTrigger.click(); for (const key of ['off', 'auto', '5s', '10s', '15s', '30s', '60s']) { @@ -101,6 +117,11 @@ test.describe('Logs refresh controls', () => { // Live cues: compact label on the split button + continuously spinning icon. await expect(visibleControl(page, 'logs-refresh-interval-label')).toHaveText('Auto'); + if (isDesktop && freshnessBefore) { + const after = (await page.getByTestId('logs-freshness').boundingBox())!; + expect(Math.abs(after.x - freshnessBefore.x)).toBeLessThanOrEqual(1); + expect(Math.abs(after.y - freshnessBefore.y)).toBeLessThanOrEqual(1); + } await expect(visibleControl(page, 'logs-refresh-button').locator('svg')).toHaveClass(/animate-\[spin_3s_linear_infinite\]/); // The immediate tick stages the new entry — the list itself must not change. diff --git a/app/src/__tests__/e2e/logs/logs-sticky-filters.spec.ts b/app/src/__tests__/e2e/logs/logs-sticky-filters.spec.ts new file mode 100644 index 00000000..c1d1ec21 --- /dev/null +++ b/app/src/__tests__/e2e/logs/logs-sticky-filters.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, type Page } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// Sticky filter bar (issue item 5): the Filters row pins below the app header while +// the list scrolls, opaque, without introducing horizontal overflow (the enabling +// App.tsx change swaps app-content's overflow-x-hidden for overflow-x-clip — `hidden` +// creates a scroll container that silently breaks position:sticky). + +const profile = { id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }; + +const manyRows = Array.from({ length: 60 }).map((_, i) => ({ + profile_id: 'prof1', + timestamp: `2026-08-13T10:${Math.floor(i / 60).toString().padStart(2, '0')}:${(59 - (i % 60)).toString().padStart(2, '0')}Z`, + status: 'processed', + protocol: 'dns', + device_id: `device-${i}`, + client_ip: `10.0.0.${i}`, + dns_request: { domain: `row-${i}.example.test`, query_type: 'A' }, +})); + +// Register the logs route AFTER registerMocks so it is tested BEFORE the catch-all +// route (Playwright matches routes in reverse registration order). +async function setupLogsPage(page: Page) { + await registerMocks(page, { authenticated: true, customProfiles: [profile] }); + await page.route(/\/api\/v1\/profiles\/prof1\/logs(\?|$)/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(manyRows) }); + }); + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').waitFor({ state: 'attached', timeout: 10000 }); +} + +test.describe('Logs sticky filter bar', () => { + test('filters pin below the header while the list scrolls, opaque, no horizontal overflow', async ({ page }) => { + await setupLogsPage(page); + const sticky = page.getByTestId('logs-sticky-filters'); + + expect(await sticky.evaluate(el => getComputedStyle(el).position)).toBe('sticky'); + + const before = (await sticky.boundingBox())!; + // scrollTo instead of mouse.wheel — the wheel API is unsupported on the + // mobile-WebKit project. + await page.evaluate(() => window.scrollTo(0, 1500)); + await page.waitForFunction(() => window.scrollY > 800); + + const after = (await sticky.boundingBox())!; + // The bar must NOT have scrolled away with the content... + expect(after.y).toBeGreaterThan(-1); + // ...and must sit exactly at its sticky offset: the full header-stack height. + const expectedTop = await page.evaluate(() => + parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--app-header-stack-full')) || 64 + ); + expect(Math.abs(after.y - expectedTop)).toBeLessThanOrEqual(2); + // Sanity: the page actually scrolled under it. + expect(before.y).toBeGreaterThanOrEqual(after.y); + + // Opaque surface — scrolled content cannot show through. + const bg = await sticky.evaluate(el => getComputedStyle(el).backgroundColor); + expect(bg).not.toBe('rgba(0, 0, 0, 0)'); + + // The overflow-x swap must not reintroduce horizontal scrolling. + const docOverflow = await page.evaluate(() => + Math.max(document.body.scrollWidth, document.documentElement.scrollWidth) - window.innerWidth + ); + expect(docOverflow).toBeLessThanOrEqual(1); + }); +}); diff --git a/app/src/__tests__/mocks/registerMocks.ts b/app/src/__tests__/mocks/registerMocks.ts index c7ae64c3..fdc8fd9c 100644 --- a/app/src/__tests__/mocks/registerMocks.ts +++ b/app/src/__tests__/mocks/registerMocks.ts @@ -91,6 +91,14 @@ export async function registerMocks(page: Page, opts: RegisterMocksOptions = {}) }, JSON.stringify(profiles)); } + // Benign default for the query-log device list so every consumer gets an empty + // dropdown seed. Specs needing specific devices register their own route AFTER + // registerMocks (wins by reverse-registration order). Logs routes in specs must + // stay anchored (`/logs(\?|$)/`) so they never swallow this endpoint. + await page.route(/\/api\/v1\/profiles\/[^/]+\/logs\/devices/i, (r: Route) => r.fulfill({ + status: 200, contentType: 'application/json', body: '[]', + })); + // Optional additional route registrations from caller if (extraRoutes) { await extraRoutes(page); diff --git a/app/src/__tests__/unit/Filters.test.tsx b/app/src/__tests__/unit/Filters.test.tsx new file mode 100644 index 00000000..d3fcab84 --- /dev/null +++ b/app/src/__tests__/unit/Filters.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { describe, expect, it, vi } from 'vitest'; +import Filters from '@/pages/logs/Filters'; + +const baseProps = { + searchInputValue: '', + onSearchInputChange: vi.fn(), + onSearchCommit: vi.fn(), + onSearchClear: vi.fn(), + committedSearchValue: '', + onClearFilters: vi.fn(), + filterValue: 'all', + onFilterChange: vi.fn(), + sortValue: 'created', + onSortChange: vi.fn(), + onRefresh: vi.fn(), + timespanValue: undefined, + onTimespanChange: vi.fn(), + refreshIntervalKey: 'off' as const, + onRefreshIntervalChange: vi.fn(), + deviceIdValue: undefined, + onDeviceIdChange: vi.fn(), + availableDeviceIds: [], +}; + +const ACCENT = 'border-[var(--tailwind-colors-rdns-600)]'; + +describe('Filters', () => { + it('gives every select trigger an accessible name', () => { + render(); + expect(screen.getByLabelText('Filter by status')).toBeInTheDocument(); + expect(screen.getByLabelText('Filter by device')).toBeInTheDocument(); + expect(screen.getByLabelText('Sort logs')).toBeInTheDocument(); + expect(screen.getByLabelText('Filter by timespan')).toBeInTheDocument(); + }); + + it('shows no accents and no clear chip at defaults', () => { + render(); + for (const label of ['Filter by status', 'Filter by device', 'Sort logs', 'Filter by timespan']) { + expect(screen.getByLabelText(label).className).not.toContain(ACCENT); + } + expect(screen.queryByTestId('logs-clear-filters')).not.toBeInTheDocument(); + }); + + it('accents exactly the active triggers and shows the clear chip', () => { + render(); + expect(screen.getByLabelText('Filter by status').className).toContain(ACCENT); + expect(screen.getByLabelText('Filter by timespan').className).toContain(ACCENT); + expect(screen.getByLabelText('Filter by device').className).not.toContain(ACCENT); + expect(screen.getByLabelText('Sort logs').className).not.toContain(ACCENT); + + const chip = screen.getByTestId('logs-clear-filters'); + fireEvent.click(chip); + expect(baseProps.onClearFilters).toHaveBeenCalled(); + }); + + it('a committed search shows the clear chip; uncommitted typing does not', () => { + const { rerender } = render(); + expect(screen.queryByTestId('logs-clear-filters')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId('logs-clear-filters')).toBeInTheDocument(); + }); + + it('search inputs expose a clear button only when text is present', () => { + const { rerender } = render(); + expect(screen.queryAllByTestId('logs-search-clear')).toHaveLength(0); + + rerender(); + // One per breakpoint instance (mobile row + desktop row). + const clears = screen.getAllByTestId('logs-search-clear'); + expect(clears.length).toBeGreaterThan(0); + fireEvent.click(clears[0]); + expect(baseProps.onSearchClear).toHaveBeenCalled(); + }); + + it('freshness label renders only after a first load, with the "Updated" idiom', () => { + const { rerender } = render(); + expect(screen.queryByTestId('logs-freshness')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId('logs-freshness')).toHaveTextContent(/^Updated 42s ago$/); + + rerender(); + expect(screen.getByTestId('logs-freshness')).toHaveTextContent(/^Updated just now$/); + }); + + it('search commits on Enter, and blur alone does not commit', () => { + render(); + const inputs = screen.getAllByLabelText('Search domain or its part'); + fireEvent.blur(inputs[0]); + expect(baseProps.onSearchCommit).not.toHaveBeenCalled(); + fireEvent.keyDown(inputs[0], { key: 'Enter' }); + expect(baseProps.onSearchCommit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/__tests__/unit/NoLogs.test.tsx b/app/src/__tests__/unit/NoLogs.test.tsx index 4d4439d2..1d661c78 100644 --- a/app/src/__tests__/unit/NoLogs.test.tsx +++ b/app/src/__tests__/unit/NoLogs.test.tsx @@ -39,4 +39,25 @@ describe('NoLogs empty state', () => { expect(screen.queryByText(/Set up modDNS on your devices/i)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /DNS Setup/i })).not.toBeInTheDocument(); }); + + it('renders filters empty state with a Clear filters action instead of the setup CTA', () => { + const onClearFilters = vi.fn(); + render(); + + expect(screen.getByText(/No matching logs/i)).toBeInTheDocument(); + expect(screen.getByText(/No results for the current filters/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /DNS Setup/i })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('logs-empty-clear-filters')); + expect(onClearFilters).toHaveBeenCalledTimes(1); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('offers Clear filters for an active search too, keeping the search copy', () => { + const onClearFilters = vi.fn(); + render(); + + expect(screen.getByText(/No logs match your search/i)).toBeInTheDocument(); + expect(screen.getByTestId('logs-empty-clear-filters')).toBeInTheDocument(); + }); }); diff --git a/app/src/__tests__/unit/QueryLogs.test.tsx b/app/src/__tests__/unit/QueryLogs.test.tsx index 14696599..15d22a6f 100644 --- a/app/src/__tests__/unit/QueryLogs.test.tsx +++ b/app/src/__tests__/unit/QueryLogs.test.tsx @@ -5,8 +5,9 @@ import QueryLogs from "@/pages/logs/Logs"; import { useAppStore } from "@/store/general"; // Hoisted mocks for vi.mock -const { queryLogsMock, profilesGetMock } = vi.hoisted(() => ({ +const { queryLogsMock, queryLogsDevicesMock, profilesGetMock } = vi.hoisted(() => ({ queryLogsMock: vi.fn(), + queryLogsDevicesMock: vi.fn(), profilesGetMock: vi.fn(), })); @@ -16,6 +17,7 @@ vi.mock("@/api/api", () => ({ Client: { queryLogsApi: { apiV1ProfilesIdLogsGet: queryLogsMock, + apiV1ProfilesIdLogsDevicesGet: queryLogsDevicesMock, }, profilesApi: { apiV1ProfilesIdGet: profilesGetMock, @@ -65,8 +67,13 @@ vi.mock("@/pages/logs/Filters", () => ({ onRefresh, onRefreshIntervalChange, isRefreshing, - }: { searchInputValue: string; onSearchInputChange?: (v: string) => void; onSearchCommit?: () => void; onFilterChange?: (v: string) => void; onSortChange?: (v: string) => void; onTimespanChange?: (v: string) => void; onDeviceIdChange?: (v: string) => void; onRefresh?: () => void; onRefreshIntervalChange?: (v: string) => void; isRefreshing?: boolean }) => ( -
+ onSearchClear, + onClearFilters, + committedSearchValue, + lastUpdatedAt, + availableDeviceIds, + }: { searchInputValue: string; onSearchInputChange?: (v: string) => void; onSearchCommit?: () => void; onFilterChange?: (v: string) => void; onSortChange?: (v: string) => void; onTimespanChange?: (v: string) => void; onDeviceIdChange?: (v: string) => void; onRefresh?: () => void; onRefreshIntervalChange?: (v: string) => void; isRefreshing?: boolean; onSearchClear?: () => void; onClearFilters?: () => void; committedSearchValue?: string; lastUpdatedAt?: number | null; availableDeviceIds?: string[] }) => ( +
({ + + @@ -167,6 +176,8 @@ describe("QueryLogs", () => { beforeEach(() => { vi.useRealTimers(); queryLogsMock.mockReset(); + queryLogsDevicesMock.mockReset(); + queryLogsDevicesMock.mockResolvedValue({ status: 200, data: [] }); profilesGetMock.mockReset(); useAppStore.setState({ activeProfile: baseProfile }); MockIntersectionObserver.lastInstance = null; @@ -585,6 +596,251 @@ describe("QueryLogs", () => { await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(100)); }); + test("search commits 500ms after typing stops, not before", async () => { + vi.useFakeTimers(); + try { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(2, 0) }); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + act(() => { + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "example" } }); + }); + // Just under the debounce window: nothing committed yet. + await act(async () => { + await vi.advanceTimersByTimeAsync(450); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + // Window elapses → one fetch with the search term. + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, undefined, undefined, undefined, "example", "created" + ); + } finally { + vi.useRealTimers(); + } + }); + + test("typing keeps postponing the debounce; Enter commits immediately", async () => { + vi.useFakeTimers(); + try { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(2, 0) }); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + // Two keystrokes 300ms apart: the first debounce window never completes. + act(() => { + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "exa" } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + act(() => { + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "example" } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + // Enter (stub's commit button) applies without waiting. + act(() => { + fireEvent.click(screen.getByTestId("commit-search")); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, undefined, undefined, undefined, "example", "created" + ); + // The trailing debounce is a no-op after the manual commit. + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + test("clear search empties both pending and committed values", async () => { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(2, 0) }); + render(); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "example" } }); + fireEvent.click(screen.getByTestId("commit-search")); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByTestId("search-clear")); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(3)); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, undefined, undefined, undefined, undefined, "created" + ); + expect((screen.getByTestId("search-input") as HTMLInputElement).value).toBe(""); + }); + + test("clear filters resets every request parameter to defaults", async () => { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(2, 0) }); + render(); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByTestId("filter-blocked")); + fireEvent.click(screen.getByTestId("device-select")); + fireEvent.click(screen.getByTestId("sort-domain")); + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "foo" } }); + fireEvent.click(screen.getByTestId("commit-search")); + await waitFor(() => expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, "blocked", undefined, "device-1", "foo", "domain" + )); + + fireEvent.click(screen.getByTestId("clear-filters")); + await waitFor(() => expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, undefined, undefined, undefined, undefined, "created" + )); + expect((screen.getByTestId("search-input") as HTMLInputElement).value).toBe(""); + }); + + // tableRef: query-logs-refresh-behaviour #L1 + test("end-of-logs marker appears only when the list is exhausted", async () => { + // Short page (5 < limit) → hasMore false → marker. + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(5, 0) }); + const { unmount } = render(); + await waitFor(() => expect(screen.getByTestId("logs-end-marker")).toBeInTheDocument()); + unmount(); + + // Full page (100 = limit) → hasMore true → no marker. + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(100, 0) }); + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(100)); + expect(screen.queryByTestId("logs-end-marker")).toBeNull(); + }); + + // tableRef: query-logs-refresh-behaviour #L2 + test("fetch failure renders the inline error card without a toast; Try again recovers", async () => { + const { toast } = await import("sonner"); + queryLogsMock.mockRejectedValueOnce({ response: { status: 500 } }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(3, 0) }); + + render(); + const card = await screen.findByTestId("logs-error"); + expect(card).toHaveTextContent("Server error occurred while loading logs."); + expect(toast.error).not.toHaveBeenCalled(); + // The empty-state onboarding card must not compete with the error. + expect(screen.queryByTestId("logs-empty-state")).toBeNull(); + + fireEvent.click(screen.getByTestId("logs-error-retry")); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(3)); + expect(screen.queryByTestId("logs-error")).toBeNull(); + }); + + // tableRef: query-logs-refresh-behaviour #L2 #T6 + test("403 stays fully silent: no error card, no toast", async () => { + const { toast } = await import("sonner"); + queryLogsMock.mockRejectedValue({ response: { status: 403 } }); + render(); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.queryByTestId("logs-error")).toBeNull()); + expect(toast.error).not.toHaveBeenCalled(); + }); + + // tableRef: query-logs-refresh-behaviour #L3 + test("page description stays static; freshness flows to the filter bar after the first load", async () => { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(3, 0) }); + render(); + // Before the load: no freshness yet. + expect(screen.getByTestId("filters")).toHaveAttribute("data-last-updated", "null"); + expect(screen.getByText(/Monitor and analyze DNS queries/)).toBeInTheDocument(); + + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(3)); + // The description is untouched by data loads; Filters received a timestamp. + expect(screen.getByText(/Monitor and analyze DNS queries/)).toBeInTheDocument(); + expect(screen.getByTestId("filters").getAttribute("data-last-updated")).not.toBe("null"); + }); + + // tableRef: query-logs-refresh-behaviour #D1 + test("device dropdown is the union of the server list and row-observed ids", async () => { + queryLogsDevicesMock.mockResolvedValue({ + status: 200, + data: [ + { device_id: "phone", last_seen: "2024-01-01T00:00:00Z" }, + { device_id: "tablet", last_seen: "2024-01-01T00:00:00Z" }, + ], + }); + queryLogsMock.mockResolvedValue({ status: 200, data: [makeLog({ device_id: "laptop" })] }); + + render(); + await waitFor(() => + expect(screen.getByTestId("filters").getAttribute("data-available-devices")).toBe("laptop,phone,tablet") + ); + expect(queryLogsDevicesMock).toHaveBeenCalledWith(baseProfile.profile_id); + }); + + // tableRef: query-logs-refresh-behaviour #D4 + test("selecting a device narrows the logs but never shrinks the device list", async () => { + queryLogsDevicesMock.mockResolvedValue({ + status: 200, + data: [ + { device_id: "device-1", last_seen: "2024-01-01T00:00:00Z" }, + { device_id: "phone", last_seen: "2024-01-01T00:00:00Z" }, + ], + }); + queryLogsMock.mockResolvedValue({ status: 200, data: [makeLog({ device_id: "device-1" })] }); + + render(); + await waitFor(() => + expect(screen.getByTestId("filters").getAttribute("data-available-devices")).toBe("device-1,phone") + ); + + fireEvent.click(screen.getByTestId("device-select")); + await waitFor(() => expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, undefined, undefined, "device-1", undefined, "created" + )); + // The regression this feature fixes: the dropdown used to collapse to the + // selected device because the list was wiped on every filter change. + expect(screen.getByTestId("filters").getAttribute("data-available-devices")).toBe("device-1,phone"); + }); + + // tableRef: query-logs-refresh-behaviour #D3 + test("device-list fetch failure degrades silently to row-observed ids", async () => { + queryLogsDevicesMock.mockRejectedValue(new Error("boom")); + queryLogsMock.mockResolvedValue({ status: 200, data: [makeLog({ device_id: "laptop" })] }); + + render(); + await waitFor(() => + expect(screen.getByTestId("filters").getAttribute("data-available-devices")).toBe("laptop") + ); + expect(screen.queryByTestId("logs-error")).toBeNull(); + }); + + // tableRef: query-logs-refresh-behaviour #D2 + test("manual refresh refetches the server device list; profile switch reloads it", async () => { + queryLogsDevicesMock.mockResolvedValue({ status: 200, data: [] }); + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(2, 0) }); + + render(); + await waitFor(() => expect(queryLogsDevicesMock).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByTestId("refresh")); + await waitFor(() => expect(queryLogsDevicesMock).toHaveBeenCalledTimes(2)); + + const otherProfile = { ...baseProfile, profile_id: "profile-2", id: "profile-2" }; + act(() => { + useAppStore.setState({ activeProfile: otherProfile }); + }); + await waitFor(() => expect(queryLogsDevicesMock).toHaveBeenLastCalledWith("profile-2")); + }); + test("shows not active state when logs disabled", async () => { const disabledProfile = { ...baseProfile, profile_id: "profile-disabled", id: "profile-disabled", settings: { logs: { enabled: false } } }; queryLogsMock.mockResolvedValue({ status: 200, data: [] }); diff --git a/app/src/api/client/api.ts b/app/src/api/client/api.ts index 181aa894..9f11dad3 100644 --- a/app/src/api/client/api.ts +++ b/app/src/api/client/api.ts @@ -1285,6 +1285,25 @@ export interface ModelQueryLog { */ 'timestamp'?: string; } +/** + * + * @export + * @interface ModelQueryLogDevice + */ +export interface ModelQueryLogDevice { + /** + * + * @type {string} + * @memberof ModelQueryLogDevice + */ + 'device_id'?: string; + /** + * + * @type {string} + * @memberof ModelQueryLogDevice + */ + 'last_seen'?: string; +} /** * * @export @@ -6444,6 +6463,40 @@ export const QueryLogsApiAxiosParamCreator = function (configuration?: Configura + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * List distinct device IDs seen in the profile\'s query logs (current retention window), each with its last-seen timestamp, sorted by device ID + * @summary Get profile query log devices + * @param {string} id Profile ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1ProfilesIdLogsDevicesGet: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('apiV1ProfilesIdLogsDevicesGet', 'id', id) + const localVarPath = `/api/v1/profiles/{id}/logs/devices` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -6579,6 +6632,19 @@ export const QueryLogsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['QueryLogsApi.apiV1ProfilesIdLogsDelete']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * List distinct device IDs seen in the profile\'s query logs (current retention window), each with its last-seen timestamp, sorted by device ID + * @summary Get profile query log devices + * @param {string} id Profile ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async apiV1ProfilesIdLogsDevicesGet(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.apiV1ProfilesIdLogsDevicesGet(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['QueryLogsApi.apiV1ProfilesIdLogsDevicesGet']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Download profile query logs * @summary Download profile query logs @@ -6632,6 +6698,16 @@ export const QueryLogsApiFactory = function (configuration?: Configuration, base apiV1ProfilesIdLogsDelete(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.apiV1ProfilesIdLogsDelete(id, options).then((request) => request(axios, basePath)); }, + /** + * List distinct device IDs seen in the profile\'s query logs (current retention window), each with its last-seen timestamp, sorted by device ID + * @summary Get profile query log devices + * @param {string} id Profile ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + apiV1ProfilesIdLogsDevicesGet(id: string, options?: RawAxiosRequestConfig): AxiosPromise> { + return localVarFp.apiV1ProfilesIdLogsDevicesGet(id, options).then((request) => request(axios, basePath)); + }, /** * Download profile query logs * @summary Download profile query logs @@ -6681,6 +6757,18 @@ export class QueryLogsApi extends BaseAPI { return QueryLogsApiFp(this.configuration).apiV1ProfilesIdLogsDelete(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * List distinct device IDs seen in the profile\'s query logs (current retention window), each with its last-seen timestamp, sorted by device ID + * @summary Get profile query log devices + * @param {string} id Profile ID + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof QueryLogsApi + */ + public apiV1ProfilesIdLogsDevicesGet(id: string, options?: RawAxiosRequestConfig) { + return QueryLogsApiFp(this.configuration).apiV1ProfilesIdLogsDevicesGet(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * Download profile query logs * @summary Download profile query logs diff --git a/app/src/components/ui/dropdown-menu.tsx b/app/src/components/ui/dropdown-menu.tsx index 7ad7a90c..7817b267 100644 --- a/app/src/components/ui/dropdown-menu.tsx +++ b/app/src/components/ui/dropdown-menu.tsx @@ -126,7 +126,7 @@ function DropdownMenuRadioItem({ void; // updates uncontrolled typing state onSearchCommit: () => void; // commit the current input value to trigger request + onSearchClear: () => void; // empty the input AND the committed value + /** Last committed search — drives the clear-filters chip, never the uncommitted typing. */ + committedSearchValue: string; + onClearFilters: () => void; filterValue: string; onFilterChange: (value: string) => void; sortValue: string; @@ -37,11 +41,89 @@ interface FiltersProps { onRefreshIntervalChange: (key: RefreshIntervalKey) => void; /** True while a manual (one-shot) refresh is in flight — spins the refresh icon. */ isRefreshing?: boolean; + /** Last successful contact with the logs endpoint; null before the first load. */ + lastUpdatedAt?: number | null; deviceIdValue: string | undefined; onDeviceIdChange: (value: string | undefined) => void; availableDeviceIds: string[]; } +// Compact relative age for a bar that refreshes in seconds — date-fns' +// formatDistanceToNow bottoms out at "less than a minute", too vague here. +const formatAge = (ms: number): string => { + const seconds = Math.max(0, Math.floor(ms / 1000)); + if (seconds < 10) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +}; + +// Freshness label beside the refresh controls. Lives in the sticky bar because that is +// the page's only persistent chrome — freshness matters most when the reader is deep in +// the list with auto-refresh off, exactly when the page description has scrolled away. +// A growing age is also the only user-visible signal when silent background ticks stop +// landing. Isolated so only this label re-renders on the 10s age tick. Deliberately NOT +// an aria-live region — announcing every tick would chatter at screen-reader users. +const FreshnessLabel = ({ lastUpdatedAt }: { lastUpdatedAt: number }): JSX.Element => { + const [, forceTick] = useState(0); + useEffect(() => { + const interval = setInterval(() => forceTick(t => t + 1), 10000); + return () => clearInterval(interval); + }, []); + return ( + + Updated {formatAge(Date.now() - lastUpdatedAt)} + + ); +}; + +// Shared search input (rendered in the mobile row and the desktop row). Commits on +// Enter or after the owner's debounce — there is deliberately no commit-on-blur (the +// old behavior committed on blur only below 1024px, measured at event time, which made +// desktop and mobile behave differently for no discernible reason). +const LogsSearchInput = ({ + value, + onChange, + onCommit, + onClear, +}: { + value: string; + onChange: (value: string) => void; + onCommit: () => void; + onClear: () => void; +}): JSX.Element => ( +
+
+ +
+ onChange(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { onCommit(); e.currentTarget.blur(); } }} + /> + {value.length > 0 && ( + + )} +
+); + // Grafana-style split refresh control, rendered in the mobile search row and at the end // of the desktop filter row. Left half: one-shot refresh (never touches the loop). Right // half: auto-refresh interval menu; while an interval is active its compact label shows @@ -60,15 +142,11 @@ const RefreshControls = ({ const activeOption = QUERY_LOGS_REFRESH_INTERVALS.find(option => option.key === refreshIntervalKey); const isAutoRefreshing = (activeOption?.ms ?? null) !== null; return ( - // `group` scopes the hover cue: pointing at either half tints the whole split - // button's border, so it reads as one control. Border-only — the `!bg` override - // (needed to sit flush on the page background) suppresses the outline variant's - // hover background, and a louder cue would compete with the filter accents. -
+
+ )} + {/* Desktop refresh controls (hidden on mobile second row) */}
-); + ); +}; export default Filters; \ No newline at end of file diff --git a/app/src/pages/logs/Logs.tsx b/app/src/pages/logs/Logs.tsx index 7aaef9c4..cce5522e 100644 --- a/app/src/pages/logs/Logs.tsx +++ b/app/src/pages/logs/Logs.tsx @@ -3,8 +3,6 @@ import type { AxiosError } from "axios"; interface NetworkError extends AxiosError { code?: string; } -import { toast } from "sonner"; - import type { ModelAccount, ModelProfile, ModelQueryLog } from "@/api/client"; import Filters from "./Filters"; import NoLogs from "./NoLogs"; @@ -16,6 +14,7 @@ import { computeNewQueryLogs } from "@/lib/queryLogsDiff"; import { refreshIntervalMsFor, type RefreshIntervalKey } from "@/lib/consts"; import api from "@/api/api"; import { useAppStore } from "@/store/general"; +import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { ArrowUp, Info, X } from "lucide-react"; import { useScreenDetector } from "@/hooks/useScreenDetector"; @@ -30,6 +29,7 @@ interface QueryLogsProps { profiles: ModelProfile[]; } + const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const { isRestricted } = useSubscriptionGuard(); const [logs, setLogs] = useState([]); @@ -68,6 +68,10 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { // groups that are actually new — not everything that remounted. const [freshIdentities, setFreshIdentities] = useState>(new Set()); + // Timestamp of the last successful contact with the logs endpoint (fetch or + // background tick) — drives the live "Last updated … ago" status line. + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + // Search input (uncommitted while typing) and committed value that triggers requests const [searchInputValue, setSearchInputValue] = useState(""); const [committedSearchValue, setCommittedSearchValue] = useState(""); @@ -76,8 +80,20 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const [timespanValue, setTimespanValue] = useState(undefined); const [deviceIdValue, setDeviceIdValue] = useState(undefined); - // Maintain a separate list of all available device IDs (not filtered by current selection) - const [allAvailableDeviceIds, setAllAvailableDeviceIds] = useState([]); + // Device filter sources: the server-authoritative distinct-device list + // (GET /profiles/{id}/logs/devices — complete within the retention window) + // plus device IDs observed in fetched rows this session (covers a brand-new + // device querying mid-session before the next server fetch). + const [serverDeviceIds, setServerDeviceIds] = useState([]); + const [observedDeviceIds, setObservedDeviceIds] = useState([]); + const [deviceListRefreshTick, setDeviceListRefreshTick] = useState(0); + // Union, with the current selection always renderable even if it vanished + // from both sources (e.g. its rows expired mid-session). + const availableDeviceIds = useMemo(() => { + const merged = new Set([...serverDeviceIds, ...observedDeviceIds]); + if (deviceIdValue) merged.add(deviceIdValue); + return Array.from(merged).sort(); + }, [serverDeviceIds, observedDeviceIds, deviceIdValue]); // id→name catalogs for enriching query-log reasons (blocklist/service ids). Loaded once on // mount; failures degrade gracefully to raw ids and must never block logs from rendering. @@ -202,17 +218,20 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { logsRef.current = logs; }, [logs]); - // Reset logs, device IDs, staged entries and page when committed filters change + // Reset logs, staged entries and page when committed filters change. The device + // list is deliberately NOT reset here: the server list is the authoritative floor + // and the union only grows within a profile session — wiping it on (device) + // selection collapsed the dropdown to the selected device. useEffect(() => { setLogs([]); setPage(1); setHasMore(true); - setAllAvailableDeviceIds([]); setIsListFading(true); setPendingLogs([]); setPendingOverflow(false); setExpandedKeys(new Set()); setFreshIdentities(new Set()); + setError(null); }, [committedSearchValue, filterValue, sortValue, timespanValue, deviceIdValue]); // Fade-in: once no fetch is in flight, release the fade after a short delay so the @@ -234,14 +253,71 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { setPendingOverflow(false); setExpandedKeys(new Set()); setFreshIdentities(new Set()); + // Old profile's devices must never bleed into the new one while the + // device-list fetch is in flight. + setServerDeviceIds([]); + setObservedDeviceIds([]); } previousProfileIdRef.current = currentId; }, [activeProfile?.profile_id]); + // Server device list: complete within the retention window, refreshed on mount, + // profile change, and every one-shot refresh. Best-effort like the catalog load — + // on failure keep the previous list and let the dropdown degrade to observed ids. + useEffect(() => { + const profileId = activeProfile?.profile_id; + if (!profileId) return; + let cancelled = false; + const loadDevices = async () => { + try { + const response = await api.Client.queryLogsApi.apiV1ProfilesIdLogsDevicesGet(profileId); + if (cancelled || response.status !== 200) return; + setServerDeviceIds( + (response.data || []) + .map(device => device.device_id) + .filter((id): id is string => Boolean(id)) + ); + } catch { + // Silent degrade — the union falls back to row-observed ids. + } + }; + loadDevices(); + return () => { cancelled = true; }; + }, [activeProfile?.profile_id, deviceListRefreshTick]); + const commitSearch = useCallback(() => { setCommittedSearchValue(prev => prev === searchInputValue ? prev : searchInputValue); }, [searchInputValue]); + // Debounce-commit: the search applies 500ms after typing stops. Enter still commits + // immediately (Filters calls commitSearch directly); its equality guard turns the + // trailing debounce into a no-op afterwards. + useEffect(() => { + const timer = setTimeout(commitSearch, 500); + return () => clearTimeout(timer); + }, [searchInputValue, commitSearch]); + + // Not routed through commitSearch — it closes over the pre-clear input value. + const handleSearchClear = useCallback(() => { + setSearchInputValue(""); + setCommittedSearchValue(""); + }, []); + + const hasNonDefaultFilters = + filterValue !== "all" || + deviceIdValue !== undefined || + sortValue !== "created" || + (timespanValue !== undefined && timespanValue !== "all"); + + const handleClearFilters = useCallback(() => { + setFilterValue("all"); + setSortValue("created"); + setTimespanValue(undefined); + setDeviceIdValue(undefined); + setSearchInputValue(""); + setCommittedSearchValue(""); + }, []); + const toggleCardExpanded = useCallback((identity: string) => { setExpandedKeys(prev => { const next = new Set(prev); @@ -266,7 +342,8 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { try { // Status is already handled in filters.Status - // Use expanded limit on first page to gather more device IDs; subsequent pages respect configured limit + // Bigger first page fills the viewport and defers the first pagination + // fetch; subsequent pages respect the configured limit. const effectiveLimit = page === 1 ? 100 : filters.Limit; const searchParam = committedSearchValue || undefined; const response = await api.Client.queryLogsApi.apiV1ProfilesIdLogsGet( @@ -289,14 +366,16 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { setLogs(prev => (page === 1 ? newLogs : [...prev, ...newLogs])); setHasMore(newLogs.length === effectiveLimit); - // Accumulate unique device IDs progressively - setAllAvailableDeviceIds(prev => { + // Merge device IDs observed in rows (union with the server list) + setObservedDeviceIds(prev => { const merged = new Set(prev); response.data.forEach(log => { if (log.device_id) merged.add(log.device_id); }); return Array.from(merged).sort(); }); + + setLastUpdatedAt(Date.now()); } else { setHasMore(false); } @@ -309,7 +388,7 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { if (status === 403) { // Account is cut off (inactive / pending_delete): logs are not // entitled in these states. AccountCutoffGuard redirects to - // /account-preferences, so surface no toast here — matching how + // /account-preferences, so surface nothing here — matching how // the other restricted pages behave during cut-off. setHasMore(false); return; @@ -323,7 +402,9 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { errorMessage = "Network error. Please check your connection."; } - toast.error(errorMessage); + // The inline error card (with its Try-again action) is the surface for + // fetch failures — a toast on top of it would double the noise. + setError(errorMessage); setHasMore(false); } finally { if (!cancelled) setLoading(false); @@ -353,6 +434,8 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { setPendingOverflow(false); setFreshIdentities(new Set()); setRefreshTrigger(prev => prev + 1); + // Refresh the server device list alongside the one-shot reload. + setDeviceListRefreshTick(prev => prev + 1); setManualSpinActive(true); if (manualSpinTimer.current) clearTimeout(manualSpinTimer.current); manualSpinTimer.current = setTimeout(() => setManualSpinActive(false), 500); @@ -387,6 +470,8 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { sortValue ); if (response.status !== 200) return; + // A tick that found nothing new still confirms freshness. + setLastUpdatedAt(Date.now()); const fetched = response.data || []; if (logsRef.current.length === 0) { // Nothing on screen to preserve — apply directly, a pill over an @@ -521,31 +606,66 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
{/* Page Description */}
-
-

- Monitor and analyze DNS queries in real-time. View blocked and processed requests for your active profile. -

-
+

+ Monitor and analyze DNS queries in real-time. View blocked and processed requests for your active profile. +

- + {/* Sticky below the app header on both breakpoints. Uses the FULL header + height var — the reduced --app-header-stack subtracts the desktop + content padding and would tuck the bar under the fixed header. z-40 + stays below the header/BottomNav (z-50); Select/dropdown popovers + portal to , unaffected. pb-1/-mb-1 mirrors the filter row's own + p-1/-m-1 focus-ring allowance so content cannot peek through at the + bottom edge while scrolled. */} +
+ +
+ + {/* Sibling of Filters and the list section so the parent's gap-6 spaces it + evenly between the two. empty:hidden collapses the slot (and its gaps) + entirely while nothing is staged; the wrapper stays mounted so the live + region exists before the pill text arrives. */} +
+ {pendingLogs.length > 0 && ( + + )} +
@@ -558,11 +678,15 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
)} - {logsEnabled && logs.length === 0 && !loading && ( + {logsEnabled && logs.length === 0 && !loading && !error && (
- 0} /> + 0} + hasActiveFilters={hasNonDefaultFilters} + onClearFilters={handleClearFilters} + />
@@ -589,22 +713,6 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
)}
- {/* Wrapper stays mounted so the live region exists before the pill text arrives. */} -
- {pendingLogs.length > 0 && ( - - )} -
{!expandHintDismissed && logs.length > 0 && (
{ ))}
)} - {error && ( -
- {error} + {error && !loading && ( +
+ {error} + +
+ )} + {/* Quiet end-of-list marker: without it the skeletons just stop + and a finished list is indistinguishable from a stalled one. */} + {!hasMore && !loading && !error && logs.length > 0 && ( +
+ End of logs
)}
diff --git a/app/src/pages/logs/NoLogs.tsx b/app/src/pages/logs/NoLogs.tsx index 33a4d92d..46726de6 100644 --- a/app/src/pages/logs/NoLogs.tsx +++ b/app/src/pages/logs/NoLogs.tsx @@ -6,6 +6,10 @@ import { useNavigate } from "react-router-dom"; interface NoLogsProps { isSearchActive?: boolean; + /** A non-default status/device/sort/timespan filter is applied. */ + hasActiveFilters?: boolean; + /** Resets every filter and the search; enables the "Clear filters" action. */ + onClearFilters?: () => void; } interface EmptyStateContent { @@ -14,7 +18,7 @@ interface EmptyStateContent { buttonText?: string; } -const emptyStateVariants: Record<"default" | "search", EmptyStateContent> = { +const emptyStateVariants: Record<"default" | "search" | "filters", EmptyStateContent> = { default: { title: "No logs to display", description: "Set up modDNS on your devices to start analysing queries.", @@ -24,11 +28,22 @@ const emptyStateVariants: Record<"default" | "search", EmptyStateContent> = { title: "No matching logs", description: "No logs match your search. Try updating the keywords or filters.", }, + filters: { + title: "No matching logs", + description: "No results for the current filters — try clearing them.", + }, }; -const NoLogs = ({ isSearchActive = false }: NoLogsProps): JSX.Element => { +const NoLogs = ({ isSearchActive = false, hasActiveFilters = false, onClearFilters }: NoLogsProps): JSX.Element => { const navigate = useNavigate(); - const emptyStateData = isSearchActive ? emptyStateVariants.search : emptyStateVariants.default; + // The DNS-setup onboarding CTA is only correct when the list is empty with NOTHING + // narrowing it — an empty filtered view means "no matches", not "not set up yet". + const isFiltered = isSearchActive || hasActiveFilters; + const emptyStateData = isSearchActive + ? emptyStateVariants.search + : hasActiveFilters + ? emptyStateVariants.filters + : emptyStateVariants.default; return ( + Clear filters + + )} + {!isFiltered && emptyStateData.buttonText && (