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 d3a88093..05b3ee80 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 new file mode 100644 index 00000000..b472a687 --- /dev/null +++ b/app/src/__tests__/e2e/logs/logs-refresh-controls.spec.ts @@ -0,0 +1,146 @@ +import { test, expect, type Page } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// Refresh controls on the Query Logs page: the icon button is a one-shot refresh and +// the labeled "Auto" toggle owns the 10s loop. Interval/pill mechanics live in unit +// tests (QueryLogs.test.tsx); here we pin the wire-level behavior and accessibility. + +const profile = { id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }; + +const logItem = (i: number, domain: string) => ({ + profile_id: 'prof1', + timestamp: `2026-08-12T10:00:${(59 - i).toString().padStart(2, '0')}Z`, + status: 'processed', + protocol: 'dns', + device_id: `device-${i}`, + client_ip: `10.0.0.${i}`, + dns_request: { domain, 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). The catch-all in +// registerMocks matches `/api/v1/profiles` and would otherwise shadow this endpoint. +// `respond` is re-evaluated per request (never keyed on call count — StrictMode +// double-fires the mount fetch in dev, so call indices are not deterministic). +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 => { + calls++; + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(respond()) }); + }); + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').waitFor({ state: 'attached', timeout: 10000 }); + return { logsCalls: () => calls }; +} + +// Two instances render (mobile row / desktop row); only one is visible per breakpoint. +const visibleControl = (page: Page, testId: string) => + page.locator(`[data-testid="${testId}"]:visible`); + +test.describe('Logs refresh controls', () => { + test('split button halves stay equal height at tablet width', async ({ page }) => { + // The Button default size carries sm:h-9, which silently shrinks the interval + // trigger below the icon half on 640-1024px viewports unless pinned. + await page.setViewportSize({ width: 834, height: 1112 }); + await setupLogsPage(page, () => [logItem(1, 'one.example.test')]); + const refresh = await visibleControl(page, 'logs-refresh-button').boundingBox(); + const trigger = await visibleControl(page, 'logs-refresh-interval-trigger').boundingBox(); + expect(refresh && trigger && refresh.height === trigger.height && refresh.y === trigger.y).toBe(true); + }); + + test('refresh button is a one-shot refresh with an accessible name', async ({ page }) => { + const { logsCalls } = await setupLogsPage(page, () => [logItem(1, 'one.example.test')]); + const initialCalls = logsCalls(); + + 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); + + // The click is acknowledged with at least one full rotation even when the + // response lands instantly... + await expect(refresh.locator('svg')).toHaveClass(/animate-spin/); + // ...then the spin stops: one-shot means no lingering animation and no + // auto-refresh mode (no interval label on the split button). + await expect(refresh.locator('svg')).not.toHaveClass(/animate-spin|animate-\[/, { timeout: 3000 }); + await expect(page.getByTestId('logs-refresh-interval-label')).toHaveCount(0); + expect(logsCalls()).toBe(initialCalls + 1); + }); + + test('interval menu enables live mode and stages new entries behind the pill', async ({ page }) => { + const initial = [logItem(1, 'one.example.test'), logItem(2, 'two.example.test')]; + const fresh = logItem(0, 'fresh.example.test'); + let dataset = initial; + await setupLogsPage(page, () => dataset); + + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + // From here on, one new entry sits above the known head — the immediate tick + // fired by enabling auto-refresh should stage it, not apply it. + dataset = [fresh, ...initial]; + + 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']) { + await expect(page.getByTestId(`logs-refresh-interval-${key}`)).toBeVisible(); + } + // Desktop: the menu opens down-right (extends past the trigger's right edge, + // into the page margin) so it doesn't drop over the quick-rule column at the + // right edge of the cards below. Radix may clamp the exact left edge to keep + // the menu inside the viewport, so assert the direction, not exact alignment. + if (test.info().project.name === 'chromium-desktop') { + const menuBox = await page.getByTestId('logs-refresh-interval-auto').boundingBox(); + const triggerBox = await intervalTrigger.boundingBox(); + expect(menuBox && triggerBox && menuBox.x + menuBox.width > triggerBox.x + triggerBox.width + 10).toBe(true); + } + await page.getByTestId('logs-refresh-interval-auto').click(); + + // 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. + const pill = page.getByTestId('logs-new-queries-pill'); + await expect(pill).toBeVisible(); + await expect(pill).toHaveText(/1 new query/); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + // Clickability affordance: pointer cursor on hover. + expect(await pill.evaluate(el => getComputedStyle(el).cursor)).toBe('pointer'); + + // Revealing prepends without a reload. + await pill.click(); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(3); + await expect(pill).toHaveCount(0); + + // Off stops live mode: label disappears, icon stops spinning. + await intervalTrigger.click(); + await page.getByTestId('logs-refresh-interval-off').click(); + await expect(page.getByTestId('logs-refresh-interval-label')).toHaveCount(0); + await expect(visibleControl(page, 'logs-refresh-button').locator('svg')).not.toHaveClass(/animate-spin|animate-\[/); + }); +}); 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/QueryLogCard.test.tsx b/app/src/__tests__/unit/QueryLogCard.test.tsx index 0a802350..47a648b2 100644 --- a/app/src/__tests__/unit/QueryLogCard.test.tsx +++ b/app/src/__tests__/unit/QueryLogCard.test.tsx @@ -160,6 +160,48 @@ describe('QueryLogCard whole-card expansion', () => { expect(screen.getByTestId('querylog-detail-domain')).toHaveTextContent('Domain logging disabled'); }); + test('controlled mode renders the expanded prop and reports toggles without flipping itself', () => { + const onToggleExpanded = vi.fn(); + const { rerender } = render( + + ); + const toggle = screen.getByTestId('querylog-card-toggle'); + fireEvent.click(toggle); + expect(onToggleExpanded).toHaveBeenCalledTimes(1); + // State is owned by the parent — the card must not expand on its own. + expect(screen.getByTestId('querylog-expanded-panel')).toHaveAttribute('data-expanded', 'false'); + + rerender(); + expect(screen.getByTestId('querylog-expanded-panel')).toHaveAttribute('data-expanded', 'true'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + test('controlled mode fires onExpand only when opening', () => { + const onExpand = vi.fn(); + const { rerender } = render( + {}} onExpand={onExpand} /> + ); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(onExpand).toHaveBeenCalledTimes(1); + + rerender( + {}} onExpand={onExpand} /> + ); + // Collapsing an open card is not an "expand". + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(onExpand).toHaveBeenCalledTimes(1); + }); + + test('animateEntry plays the entry animation with a reduced-motion escape', () => { + const { container, rerender } = render(); + const root = container.firstElementChild as HTMLElement; + expect(root.className).toContain('animate-in'); + expect(root.className).toContain('motion-reduce:animate-none'); + + rerender(); + expect((container.firstElementChild as HTMLElement).className).not.toContain('animate-in'); + }); + test('there is no visible chevron indicator', () => { render(); expect(screen.queryByTestId('querylog-expand-indicator')).not.toBeInTheDocument(); diff --git a/app/src/__tests__/unit/QueryLogs.test.tsx b/app/src/__tests__/unit/QueryLogs.test.tsx index 4bbcdeb8..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, @@ -33,7 +35,7 @@ vi.mock("@/pages/logs/QuickRuleSheet", () => ({ vi.mock("@/pages/logs/QueryLogCard", () => ({ __esModule: true, - default: function MockQueryLogCard({ log, onQuickRule, lastLogRef, isLast }: { log: { status: string; dns_request?: { domain: string } }; onQuickRule?: (domain: string, action: string) => void; lastLogRef?: (el: HTMLDivElement) => void; isLast?: boolean }) { + default: function MockQueryLogCard({ log, onQuickRule, lastLogRef, isLast, animateEntry }: { log: { status: string; dns_request?: { domain: string } }; onQuickRule?: (domain: string, action: string) => void; lastLogRef?: (el: HTMLDivElement) => void; isLast?: boolean; animateEntry?: boolean }) { React.useEffect(() => { if (lastLogRef) { const el = document.createElement("div"); @@ -41,7 +43,7 @@ vi.mock("@/pages/logs/QueryLogCard", () => ({ } }, [lastLogRef, isLast]); return ( -
+
- + + + + +
), })); @@ -150,10 +162,22 @@ const makeLog = (overrides: Record = {}) => ({ ...overrides, }); +// Distinct domains + timestamps so entries neither consolidate nor collide in the +// background-tick diff. `offset` keeps batches disjoint across mock responses. +const distinctLogs = (count: number, offset: number) => + Array.from({ length: count }).map((_, i) => + makeLog({ + dns_request: { domain: `d${offset + i}.example.com` }, + timestamp: `2024-01-01T00:${Math.floor((offset + i) / 60).toString().padStart(2, "0")}:${((offset + i) % 60).toString().padStart(2, "0")}Z`, + }) + ); + describe("QueryLogs", () => { beforeEach(() => { vi.useRealTimers(); queryLogsMock.mockReset(); + queryLogsDevicesMock.mockReset(); + queryLogsDevicesMock.mockResolvedValue({ status: 200, data: [] }); profilesGetMock.mockReset(); useAppStore.setState({ activeProfile: baseProfile }); MockIntersectionObserver.lastInstance = null; @@ -264,26 +288,18 @@ describe("QueryLogs", () => { await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); }); - test("keeps cards visible when auto-refresh is toggled and pagination fires during refresh", async () => { - // Regression test for the auto-refresh "invisible cards" bug: the list container was - // faded to opacity-0 on every page-1 refresh and only restored by a 100ms setTimeout - // that the fetch effect's cleanup cancels. An IntersectionObserver page bump inside + test("keeps cards visible when a manual refresh and pagination overlap", async () => { + // Regression test for the "invisible cards" bug: the list container is faded to + // opacity-0 on every page-1 refresh and only restored by a 100ms setTimeout that + // the fetch effect's cleanup cancels. An IntersectionObserver page bump inside // that window (opacity-0 elements still intersect) left the cards mounted and // clickable but permanently invisible. vi.useFakeTimers(); try { - const distinctLogs = (count: number, offset: number) => - Array.from({ length: count }).map((_, i) => - makeLog({ - dns_request: { domain: `d${offset + i}.example.com` }, - timestamp: `2024-01-01T00:${Math.floor((offset + i) / 60).toString().padStart(2, "0")}:${((offset + i) % 60).toString().padStart(2, "0")}Z`, - }) - ); - // Call 1: initial load (page 1, limit 100). Call 2: refresh triggered by the - // auto-refresh toggle (page 1, limit 25 → full page, so hasMore recomputes true). - // Call 3: the observer-driven page-2 fetch. + // Call 1: initial load (page 1, limit 100 → full page, hasMore true). + // Call 2: the manual one-shot refresh. Call 3: the observer-driven page-2 fetch. queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(100, 0) }); - queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(25, 100) }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(100, 200) }); queryLogsMock.mockResolvedValueOnce({ status: 200, data: [] }); render(); @@ -295,7 +311,7 @@ describe("QueryLogs", () => { expect(screen.getAllByTestId("log-card").length).toBeGreaterThan(0); act(() => { - fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + fireEvent.click(screen.getByTestId("refresh")); }); // Previous data must stay on screen while the refresh is in flight — no blank flash. expect(screen.getAllByTestId("log-card").length).toBeGreaterThan(0); @@ -312,10 +328,9 @@ describe("QueryLogs", () => { act(() => { MockIntersectionObserver.lastInstance?.trigger([{ isIntersecting: true } as IntersectionObserverEntry]); }); - // Let everything settle (stay below the 10s auto-refresh interval). Two advances: - // the page-2 fetch resolves during the first; the fade-in timer it schedules is - // created in a passive effect flushed at the end of that act block, so a second - // advance is needed for it to fire. + // Let everything settle. Two advances: the page-2 fetch resolves during the + // first; the fade-in timer it schedules is created in a passive effect flushed + // at the end of that act block, so a second advance is needed for it to fire. await act(async () => { await vi.advanceTimersByTimeAsync(500); }); @@ -332,6 +347,500 @@ describe("QueryLogs", () => { } }); + // tableRef: query-logs-refresh-behaviour #C1 + test("manual refresh refetches page 1 with limit 100 and replaces the list", async () => { + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(5, 0) }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(2, 100) }); + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + + fireEvent.click(screen.getByTestId("refresh")); + + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, + 1, + 100, + undefined, + undefined, + undefined, + undefined, + "created" + ); + }); + + // tableRef: query-logs-refresh-behaviour #C2 #T4 #P1 + test("auto-refresh stages new entries behind a pill instead of replacing the list", async () => { + const initial = distinctLogs(5, 0); + const fresh = distinctLogs(2, 100); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: initial }); + // Immediate tick fired by enabling auto-refresh: two new entries above the known head. + queryLogsMock.mockResolvedValueOnce({ status: 200, data: [...fresh, ...initial] }); + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + // Let the initial 100ms fade-in release so the opacity assertion below can only + // trip on a fade restarted by the tick. + await waitFor(() => + expect(screen.getByTestId("logs-scroll-container").querySelector(".opacity-0")).toBeNull() + ); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + + const pill = await screen.findByTestId("logs-new-queries-pill"); + expect(pill).toHaveTextContent("2 new queries"); + // The tick must not have touched the list: same cards, no fade restart. + expect(screen.getAllByTestId("log-card")).toHaveLength(5); + expect(screen.getByTestId("logs-scroll-container").querySelector(".opacity-0")).toBeNull(); + + fireEvent.click(pill); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(7)); + expect(screen.queryByTestId("logs-new-queries-pill")).toBeNull(); + // Revealing staged entries is purely client-side — no extra request. + expect(queryLogsMock).toHaveBeenCalledTimes(2); + // Only the revealed entries play the entry animation — the prepend remounts + // every card, so pre-existing rows must not re-animate. + const animateFlags = screen.getAllByTestId("log-card").map(card => card.getAttribute("data-animate-entry")); + expect(animateFlags).toEqual(["true", "true", "false", "false", "false", "false", "false"]); + }); + + // tableRef: query-logs-refresh-behaviour #P3 + test("clears staged entries when a filter changes", async () => { + const initial = distinctLogs(5, 0); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: initial }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: [...distinctLogs(1, 100), ...initial] }); + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(3, 200) }); + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + await screen.findByTestId("logs-new-queries-pill"); + + fireEvent.click(screen.getByTestId("filter-blocked")); + await waitFor(() => expect(screen.queryByTestId("logs-new-queries-pill")).toBeNull()); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(3)); + }); + + // tableRef: query-logs-refresh-behaviour #C1 + test("manual refresh spins the icon for at least half a second even on instant responses", async () => { + vi.useFakeTimers(); + try { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(3, 0) }); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + expect(screen.getByTestId("filters")).toHaveAttribute("data-refreshing", "false"); + + act(() => { + fireEvent.click(screen.getByTestId("refresh")); + }); + // The response resolves in microtasks, yet the spin must hold... + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect(screen.getByTestId("filters")).toHaveAttribute("data-refreshing", "true"); + // ...until the 500ms half-rotation minimum elapses. + await act(async () => { + await vi.advanceTimersByTimeAsync(350); + }); + expect(screen.getByTestId("filters")).toHaveAttribute("data-refreshing", "false"); + } finally { + vi.useRealTimers(); + } + }); + + // tableRef: query-logs-refresh-behaviour #C2 + test("ticks at the selected interval and stops when switched off", async () => { + vi.useFakeTimers(); + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(false); + try { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(3, 0) }); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + // Selecting 5s fires the immediate enable tick (call 2)... + act(() => { + fireEvent.click(screen.getByTestId("refresh-interval-5s")); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + + // ...then one tick per 5s window. + await act(async () => { + await vi.advanceTimersByTimeAsync(5100); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(3); + + // Off stops the loop entirely. + act(() => { + fireEvent.click(screen.getByTestId("refresh-interval-off")); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(3); + } finally { + hiddenSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + // tableRef: query-logs-refresh-behaviour #T1 + test("skips background ticks while the tab is hidden and catches up on return", async () => { + vi.useFakeTimers(); + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(false); + try { + const initial = distinctLogs(5, 0); + queryLogsMock.mockResolvedValue({ status: 200, data: initial }); + + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + // Enable auto-refresh: the immediate tick is call 2. + act(() => { + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + + // Hidden tab: interval ticks self-skip without fetching. + hiddenSpy.mockReturnValue(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(25000); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + + // Back to visible: the visibilitychange handler fires an immediate catch-up tick. + hiddenSpy.mockReturnValue(false); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + await vi.advanceTimersByTimeAsync(0); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(3); + } finally { + hiddenSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + // tableRef: query-logs-refresh-behaviour #T2 + test("falls back to a full replace on tick when sorted by domain", async () => { + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(5, 0) }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(4, 100) }); // sort-change refetch + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(2, 200) }); // tick fallback replace + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + + fireEvent.click(screen.getByTestId("sort-domain")); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(4)); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + // Non-temporal sort: the tick replaces the list wholesale; nothing is staged. + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); + expect(screen.queryByTestId("logs-new-queries-pill")).toBeNull(); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, + 1, + 100, + undefined, + undefined, + undefined, + undefined, + "domain" + ); + }); + + // tableRef: query-logs-refresh-behaviour #T3 + test("applies tick data directly when the list is empty", async () => { + queryLogsMock.mockResolvedValueOnce({ status: 200, data: [] }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(3, 0) }); + + render(); + await screen.findByTestId("logs-empty-state"); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(3)); + expect(screen.queryByTestId("logs-new-queries-pill")).toBeNull(); + }); + + // tableRef: query-logs-refresh-behaviour #T5 #P2 + test("shows 100+ and reloads when the tick shares nothing with the list", async () => { + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(5, 0) }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(100, 100) }); // full page, no overlap + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(100, 100) }); // reload after pill click + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + const pill = await screen.findByTestId("logs-new-queries-pill"); + expect(pill).toHaveTextContent("100+ new queries"); + + // A gapped prepend would misorder the list — the pill triggers a full reload instead. + fireEvent.click(pill); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(3)); + 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/__tests__/unit/lib/consolidateLogs.test.ts b/app/src/__tests__/unit/lib/consolidateLogs.test.ts index b2df2947..76661834 100644 --- a/app/src/__tests__/unit/lib/consolidateLogs.test.ts +++ b/app/src/__tests__/unit/lib/consolidateLogs.test.ts @@ -161,4 +161,18 @@ describe('consolidateLogs', () => { expect(g.queryTypes).toEqual(['A']); expect(g.representative.dns_request?.domain).toBe('a.com'); }); + + it('identity is stable when entries are prepended above the group, key is not', () => { + const existing = [ + log({ domain: 'stable.com', query_type: 'A', timestamp: '2026-06-15T10:00:00.000Z' }), + ]; + const prepended = [ + log({ domain: 'newer.com', query_type: 'A', timestamp: '2026-06-15T10:00:05.000Z' }), + ...existing, + ]; + const before = consolidateLogs(existing)[0]; + const after = consolidateLogs(prepended)[1]; + expect(after.identity).toBe(before.identity); + expect(after.key).not.toBe(before.key); + }); }); diff --git a/app/src/__tests__/unit/lib/queryLogsDiff.test.ts b/app/src/__tests__/unit/lib/queryLogsDiff.test.ts new file mode 100644 index 00000000..7bfdd400 --- /dev/null +++ b/app/src/__tests__/unit/lib/queryLogsDiff.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from "vitest"; +import { computeNewQueryLogs, logIdentity } from "@/lib/queryLogsDiff"; +import type { ModelQueryLog } from "@/api/client"; + +const log = (overrides: Partial & { domain?: string } = {}): ModelQueryLog => { + const { domain, ...rest } = overrides; + return { + profile_id: "profile-1", + timestamp: "2024-01-01T00:00:00Z", + status: "processed", + dns_request: { domain: domain ?? "example.com", query_type: "A" }, + device_id: "device-1", + client_ip: "10.0.0.1", + protocol: "udp", + ...rest, + } as ModelQueryLog; +}; + +describe("logIdentity", () => { + test("prefers the server id when present", () => { + expect(logIdentity(log({ id: "abc" }))).toBe("abc"); + }); + + test("composite fallback discriminates same-second bursts by query type", () => { + const a = log(); + const aaaa = { ...log(), dns_request: { domain: "example.com", query_type: "AAAA" } }; + expect(logIdentity(a)).not.toBe(logIdentity(aaaa)); + }); + + test("identical entries share an identity", () => { + expect(logIdentity(log())).toBe(logIdentity(log())); + }); +}); + +describe("computeNewQueryLogs", () => { + test("returns the prefix above the first overlapping entry", () => { + const current = [log({ domain: "c1.test" }), log({ domain: "c2.test" })]; + const fetched = [log({ domain: "n1.test" }), log({ domain: "n2.test" }), ...current]; + const diff = computeNewQueryLogs(fetched, current); + expect(diff.newLogs.map(l => l.dns_request?.domain)).toEqual(["n1.test", "n2.test"]); + expect(diff.overlapFound).toBe(true); + }); + + test("matches on server ids when available", () => { + const current = [log({ id: "x1" }), log({ id: "x2" })]; + const fetched = [log({ id: "x9", domain: "new.test" }), ...current]; + const diff = computeNewQueryLogs(fetched, current); + expect(diff.newLogs).toHaveLength(1); + expect(diff.overlapFound).toBe(true); + }); + + test("no new entries when the head is unchanged", () => { + const current = [log({ domain: "c1.test" }), log({ domain: "c2.test" })]; + const diff = computeNewQueryLogs([...current], current); + expect(diff.newLogs).toHaveLength(0); + expect(diff.overlapFound).toBe(true); + }); + + test("reports no overlap when the lists are disjoint", () => { + const current = [log({ domain: "old.test" })]; + const fetched = [log({ domain: "n1.test" }), log({ domain: "n2.test" })]; + const diff = computeNewQueryLogs(fetched, current); + expect(diff.newLogs).toHaveLength(2); + expect(diff.overlapFound).toBe(false); + }); + + test("empty current list: everything is new, no overlap", () => { + const diff = computeNewQueryLogs([log()], []); + expect(diff.newLogs).toHaveLength(1); + expect(diff.overlapFound).toBe(false); + }); + + test("empty fetch: nothing new, no overlap", () => { + const diff = computeNewQueryLogs([], [log()]); + expect(diff.newLogs).toHaveLength(0); + expect(diff.overlapFound).toBe(false); + }); + + test("same-second burst entries only match their exact counterpart", () => { + // A + AAAA at the same timestamp: fetching one more AAAA for a new domain must + // not be swallowed by the timestamp-equal A entry. + const a = log({ domain: "dup.test" }); + const current = [a]; + const aaaa = { ...log({ domain: "dup.test" }), dns_request: { domain: "dup.test", query_type: "AAAA" } }; + const diff = computeNewQueryLogs([aaaa, a], current); + expect(diff.newLogs).toHaveLength(1); + expect(diff.overlapFound).toBe(true); + }); +}); 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({ + QUERY_LOGS_REFRESH_INTERVALS.find(option => option.key === key)?.ms ?? null; \ No newline at end of file diff --git a/app/src/lib/queryLogsDiff.ts b/app/src/lib/queryLogsDiff.ts new file mode 100644 index 00000000..a2bac9db --- /dev/null +++ b/app/src/lib/queryLogsDiff.ts @@ -0,0 +1,55 @@ +// queryLogsDiff — diff a freshly fetched page 1 against the displayed logs list. +// +// Used by the auto-refresh background tick: instead of replacing the list wholesale +// (which reset scroll and collapsed expanded cards), the tick computes which fetched +// entries are genuinely new and stages them behind a "N new queries" pill. + +import type { ModelQueryLog } from "@/api/client"; + +// How many entries from the head of the displayed list to index when looking for the +// overlap point. Ticks fetch 100 rows, so the overlap — if any — sits within the first +// 100 displayed entries; 150 leaves slack for pill merges between ticks. +const OVERLAP_WINDOW = 150; + +export interface QueryLogsDiff { + /** Entries in `fetched` newer than the displayed head, in fetched (newest-first) order. */ + newLogs: ModelQueryLog[]; + /** False when `fetched` shares no entry with the displayed head — the lists don't touch. */ + overlapFound: boolean; +} + +// Identity of one log entry for diffing. Prefers the server id; the fallback composite +// includes the timestamp AND the consolidation-signature fields plus query_type, because +// timestamps alone cannot discriminate — DNS bursts (A + AAAA) land in the same second. +export const logIdentity = (log: ModelQueryLog): string => + log.id || + [ + log.timestamp ?? "", + log.dns_request?.domain ?? "", + log.dns_request?.query_type ?? "", + log.status ?? "", + log.device_id ?? "", + log.client_ip ?? "", + log.protocol ?? "", + ].join("|"); + +/** + * Walk `fetched` (newest first) until the first entry already present near the head of + * `current`; the prefix before that point is new. No overlap means `fetched` is entirely + * unseen — at a full page size that implies a gap, which the caller must handle by + * replacing instead of prepending. Pure, O(n). + */ +export function computeNewQueryLogs( + fetched: ModelQueryLog[], + current: ModelQueryLog[] +): QueryLogsDiff { + const known = new Set(current.slice(0, OVERLAP_WINDOW).map(logIdentity)); + const newLogs: ModelQueryLog[] = []; + for (const log of fetched) { + if (known.has(logIdentity(log))) { + return { newLogs, overlapFound: true }; + } + newLogs.push(log); + } + return { newLogs, overlapFound: false }; +} diff --git a/app/src/pages/logs/Filters.tsx b/app/src/pages/logs/Filters.tsx index 85e5d5e4..41aa22d9 100644 --- a/app/src/pages/logs/Filters.tsx +++ b/app/src/pages/logs/Filters.tsx @@ -1,7 +1,18 @@ -import { type JSX } from "react"; -import { Search, ListFilter, ArrowDownAZ, RefreshCw, Monitor, Clock } from "lucide-react"; +import { useEffect, useState, type JSX } from "react"; +import { Search, ListFilter, ArrowDownAZ, RefreshCw, Monitor, Clock, ChevronDown, X } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + QUERY_LOGS_REFRESH_INTERVALS, + type RefreshIntervalKey, +} from "@/lib/consts"; import { Select, SelectContent, @@ -14,6 +25,10 @@ interface FiltersProps { searchInputValue: string; // current text in the search input onSearchInputChange: (value: string) => 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; @@ -21,17 +36,179 @@ interface FiltersProps { onRefresh: () => void; timespanValue: string | undefined; onTimespanChange: (value: string | undefined) => void; - isAutoRefreshing?: boolean; - onToggleAutoRefresh?: () => void; + /** Selected auto-refresh cadence ("off" = disabled). */ + refreshIntervalKey: RefreshIntervalKey; + 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 +// on the button and the refresh icon spins continuously (the "live" cue). +const RefreshControls = ({ + onRefresh, + isRefreshing, + refreshIntervalKey, + onRefreshIntervalChange, +}: { + onRefresh: () => void; + isRefreshing: boolean; + refreshIntervalKey: RefreshIntervalKey; + onRefreshIntervalChange: (key: RefreshIntervalKey) => void; +}): JSX.Element => { + const activeOption = QUERY_LOGS_REFRESH_INTERVALS.find(option => option.key === refreshIntervalKey); + const isAutoRefreshing = (activeOption?.ms ?? null) !== null; + return ( +
+ + + + + + {/* align="start": open down-right so the menu doesn't drop over the + quick-rule column at the right edge of the cards below. Radix + collision handling still flips it where the viewport is too narrow. */} + + onRefreshIntervalChange(value as RefreshIntervalKey)} + > + {QUERY_LOGS_REFRESH_INTERVALS.map(option => ( + + {option.label} + + ))} + + + +
+ ); +}; + const Filters = ({ searchInputValue, onSearchInputChange, onSearchCommit, + onSearchClear, + committedSearchValue, + onClearFilters, filterValue, onFilterChange, sortValue, @@ -39,41 +216,58 @@ const Filters = ({ onRefresh, timespanValue, onTimespanChange, - isAutoRefreshing = false, - onToggleAutoRefresh, + refreshIntervalKey, + onRefreshIntervalChange, + isRefreshing = false, + lastUpdatedAt = null, deviceIdValue, onDeviceIdChange, availableDeviceIds, -}: FiltersProps): JSX.Element => ( +}: FiltersProps): JSX.Element => { + // Below `md` the Select values are hidden, so the accent border/icon is the ONLY + // signal that a filter narrows the list. + const statusActive = filterValue !== "all"; + const deviceActive = deviceIdValue !== undefined; + const sortActive = sortValue !== "created"; + const timespanActive = timespanValue !== undefined && timespanValue !== "all"; + const searchCommitted = committedSearchValue.trim().length > 0; + const anyActive = statusActive || deviceActive || sortActive || timespanActive || searchCommitted; + // The base SelectTrigger's focus-visible ring + border-ring fires on Radix's + // programmatic refocus after picking an option, painting a gray outline over the + // accent border — suppress it and keep the border tracking the active state. + // Active accent matches the query-log cards' hover/open outline (full rdns-600 on + // light, /40 on dark) so the two surfaces share one visual language. + const triggerBorder = (active: boolean) => + active + ? "border-[var(--tailwind-colors-rdns-600)] dark:border-[var(--tailwind-colors-rdns-600)]/40 focus-visible:border-[var(--tailwind-colors-rdns-600)] dark:focus-visible:border-[var(--tailwind-colors-rdns-600)]/40 focus-visible:ring-0" + : "border-[var(--tailwind-colors-slate-600)] focus-visible:border-[var(--tailwind-colors-slate-600)] focus-visible:ring-0"; + const iconTint = (active: boolean) => (active ? "text-[var(--tailwind-colors-rdns-600)]" : ""); + return ( <> + {/* Freshness line above the controls row, right-aligned over the refresh button. + Deliberately NOT inline with the row: the split button widens when an interval + is active and the search input flexes, so an inline label would jitter. The + height is reserved from the start (desktop) so the label's appearance after + the first load shifts nothing. */} +
+ {lastUpdatedAt !== null && } +
{/* Tablet layout adjustment: two-row layout persists through md (tablets). Desktop (>=lg) collapses to one row. */}
{/* Row 1: search + refresh (mobile). Desktop: all inline revert -> wrap both rows into one flex row via md:hidden/md:flex patterns */}
-
-
- -
- onSearchInputChange(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { onSearchCommit(); e.currentTarget.blur(); } }} - onBlur={() => { if (window.innerWidth < 1024) onSearchCommit(); }} - /> -
- + +
{/* Row 2 (mobile: single horizontal scroll line) / Full single row (desktop). @@ -81,27 +275,20 @@ const Filters = ({ controls inside the overflow-x-auto clip box without shifting layout. */}
{/* Desktop search (hidden on mobile) */} -
-
- -
- + onSearchInputChange(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { onSearchCommit(); e.currentTarget.blur(); } }} - onBlur={() => { if (window.innerWidth < 1024) onSearchCommit(); }} + onChange={onSearchInputChange} + onCommit={onSearchCommit} + onClear={onSearchClear} />
{/* Query filter */} - +
- +
@@ -153,9 +340,9 @@ const Filters = ({ value={timespanValue ?? "all"} onValueChange={val => onTimespanChange(val === "all" ? undefined : val)} > - +
- +
@@ -169,21 +356,35 @@ const Filters = ({ - {/* Desktop refresh button (hidden on mobile second row) */} -
+ {/* Clear-all chip: appears once anything narrows the list (a non-default + select OR a committed search — never uncommitted typing). */} + {anyActive && ( + )} + + {/* 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 86a8ac0e..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"; @@ -12,10 +10,13 @@ import LogsNotActive from "./LogsNotActive"; import QueryLogCard from "./QueryLogCard"; import QuickRuleSheet, { type QuickRuleAction } from "./QuickRuleSheet"; import { consolidateLogs, toSingletonGroup } from "@/lib/consolidateLogs"; +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 { Info, X } from "lucide-react"; +import { ArrowUp, Info, X } from "lucide-react"; import { useScreenDetector } from "@/hooks/useScreenDetector"; import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; import LimitedAccessBanner from "@/components/LimitedAccessBanner"; @@ -28,6 +29,7 @@ interface QueryLogsProps { profiles: ModelProfile[]; } + const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const { isRestricted } = useSubscriptionGuard(); const [logs, setLogs] = useState([]); @@ -35,7 +37,11 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [isAutoRefreshing, setIsAutoRefreshing] = useState(false); + // Auto-refresh cadence, selected via the split refresh button's interval menu + // ("off" = disabled). Session-only by design — never persisted. + const [refreshIntervalKey, setRefreshIntervalKey] = useState("off"); + const refreshIntervalMs = refreshIntervalMsFor(refreshIntervalKey); + const isAutoRefreshing = refreshIntervalMs !== null; const [refreshTrigger, setRefreshTrigger] = useState(0); // Add trigger for forced refresh // Fade choreography for page-1 loads: true = list held at opacity-0. Starts true so the // initial load fades in. Set true by every refresh/filter trigger; cleared ONLY by the @@ -46,6 +52,26 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const [quickRuleDomain, setQuickRuleDomain] = useState(undefined); const [quickRuleDefaultAction, setQuickRuleDefaultAction] = useState("denylist"); + // New entries found by the auto-refresh background tick, staged behind the + // "N new queries" pill instead of disturbing the list. Recomputed wholesale + // against the displayed list on every tick. pendingOverflow: the tick's full + // page shared nothing with the list — prepending would leave a gap. + const [pendingLogs, setPendingLogs] = useState([]); + const [pendingOverflow, setPendingOverflow] = useState(false); + + // Expansion state of cards, lifted here (keyed by group identity, not React key) + // so open cards survive the remounts caused by refreshes and pill merges. + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + + // Group identities revealed by the last pill click. A prepend remounts EVERY card + // (React keys embed the list index), so the entry animation must be scoped to the + // 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(""); @@ -54,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. @@ -82,6 +120,10 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const observer = useRef(null); const previousProfileIdRef = useRef(undefined); + // Mirror of `logs` for reads inside the background tick, which runs outside the + // render cycle (setInterval) and must diff against the list as displayed NOW. + const logsRef = useRef([]); + const bgFetchInFlight = useRef(false); const lastLogRef = useCallback( (node: HTMLDivElement | null) => { if (loading) return; @@ -172,13 +214,24 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { } }, []); - // Reset logs, device IDs and page when committed filters change + useEffect(() => { + logsRef.current = logs; + }, [logs]); + + // 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 @@ -196,14 +249,84 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { if (previousProfileIdRef.current && previousProfileIdRef.current !== currentId) { setIsQuickRuleSheetOpen(false); setQuickRuleDomain(undefined); + setPendingLogs([]); + 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); + if (next.has(identity)) next.delete(identity); + else next.add(identity); + return next; + }); + }, []); + // Fetch logs and then fetch logos for the batch useEffect(() => { let cancelled = false; @@ -219,8 +342,9 @@ 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 - const effectiveLimit = (page === 1 && !isAutoRefreshing) ? 100 : filters.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( activeProfile.profile_id, @@ -242,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); } @@ -262,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; @@ -276,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); @@ -286,48 +414,141 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- committedSearchValue, isAutoRefreshing, and sortValue are consumed via the `filters` object and `refreshTrigger`; adding them directly would cause redundant re-fetches since the filters object already captures their derived values + // eslint-disable-next-line react-hooks/exhaustive-deps -- committedSearchValue and sortValue are consumed via the `filters` object and `refreshTrigger`; adding them directly would cause redundant re-fetches since the filters object already captures their derived values }, [page, filters.Limit, filters.Status, filters.Timespan.Value, filters.Search, filters.Sort, activeProfile, refreshTrigger, deviceIdValue]); - // Auto-refresh effect - useEffect(() => { - let interval: NodeJS.Timeout | null = null; - - if (isAutoRefreshing && activeProfile?.profile_id) { - interval = setInterval(() => { - // Force refresh by incrementing trigger and resetting to first page. - // The current list stays on screen until the page-1 response replaces it - // wholesale — clearing it here would blank (or, with the old fade logic, - // permanently hide) the cards on every tick. - setPage(1); - setIsListFading(true); - setRefreshTrigger(prev => prev + 1); - }, 10000); // 10 seconds + // Spin the refresh icon for at least a half rotation (500ms) per manual refresh — + // tied to `loading` alone, a fast response ends the spin after a couple of frames + // and the click appears to do nothing. + const [manualSpinActive, setManualSpinActive] = useState(false); + const manualSpinTimer = useRef | null>(null); + useEffect(() => () => { + if (manualSpinTimer.current) clearTimeout(manualSpinTimer.current); + }, []); + + // Handle manual (one-shot) refresh: page-1 replace, discarding staged entries. + const handleRefresh = () => { + setPage(1); + setIsListFading(true); + setPendingLogs([]); + 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); + }; + + const logsEnabled = + activeProfile?.settings?.logs.enabled !== false; // default to true if undefined + + // Auto-refresh background tick: fetch page 1 and diff it against the displayed + // list; genuinely new entries wait behind the "N new queries" pill instead of + // replacing the list (which reset scroll position and collapsed open cards). + const runBackgroundTick = async () => { + const profileId = activeProfile?.profile_id; + if (document.hidden || !profileId || !logsEnabled) return; + if (loading || bgFetchInFlight.current) return; + if (sortValue !== "created") { + // Non-temporal sorts have no meaningful prepend point — fall back to the + // wholesale page-1 replace. + handleRefresh(); + return; + } + bgFetchInFlight.current = true; + try { + const response = await api.Client.queryLogsApi.apiV1ProfilesIdLogsGet( + profileId, + 1, + 100, + filters.Status, + filters.Timespan.Value, + deviceIdValue || undefined, + committedSearchValue || undefined, + 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 + // empty state helps no one. + setLogs(fetched); + setHasMore(fetched.length === 100); + setPendingLogs([]); + setPendingOverflow(false); + return; + } + const { newLogs, overlapFound } = computeNewQueryLogs(fetched, logsRef.current); + setPendingLogs(newLogs); + setPendingOverflow(!overlapFound && fetched.length === 100); + } catch { + // Background ticks fail silently — the next tick retries; foreground + // fetches own user-visible error reporting. + } finally { + bgFetchInFlight.current = false; } + }; + // Latest-closure ref so the interval (bound once per auto-refresh session) always + // calls a tick that sees current filters/logs without restarting the timer. + const tickRef = useRef(runBackgroundTick); + useEffect(() => { + tickRef.current = runBackgroundTick; + }); + // Auto-refresh loop at the selected cadence: paused while the tab is hidden (the + // tick self-skips), with an immediate catch-up tick on return to a visible tab. + useEffect(() => { + if (refreshIntervalMs === null || !activeProfile?.profile_id) return; + const interval = setInterval(() => { + void tickRef.current(); + }, refreshIntervalMs); + const onVisibilityChange = () => { + if (!document.hidden) void tickRef.current(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); return () => { - if (interval) { - clearInterval(interval); - } + clearInterval(interval); + document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [isAutoRefreshing, activeProfile?.profile_id]); - - // Handle auto-refresh toggle - const handleToggleAutoRefresh = () => { - setIsAutoRefreshing(prev => !prev); - if (!isAutoRefreshing) { - // When starting auto-refresh, immediately refresh once - setPage(1); - setIsListFading(true); - setRefreshTrigger(prev => prev + 1); + }, [refreshIntervalMs, activeProfile?.profile_id]); + + // Interval menu selection + const handleRefreshIntervalChange = (key: RefreshIntervalKey) => { + const wasOn = isAutoRefreshing; + setRefreshIntervalKey(key); + if (refreshIntervalMsFor(key) === null) { + setPendingLogs([]); + setPendingOverflow(false); + } else if (!wasOn) { + // Immediate feedback without disturbing the current list; interval-to- + // interval changes just retime the loop. + void tickRef.current(); } }; - // Handle manual refresh - const handleRefresh = () => { - setPage(1); - setIsListFading(true); - setRefreshTrigger(prev => prev + 1); + // Reveal staged entries: prepend them above the current list. When the tick found + // a full page with no overlap, prepending would leave a gap — reload instead. + const handleShowPending = () => { + if (pendingOverflow) { + handleRefresh(); + return; + } + const previous = logsRef.current; + const merged = [...pendingLogs, ...previous]; + const previousIdentities = new Set(consolidateLogs(previous).map(group => group.identity)); + setFreshIdentities( + new Set( + consolidateLogs(merged) + .map(group => group.identity) + .filter(identity => !previousIdentities.has(identity)) + ) + ); + setLogs(merged); + setPendingLogs([]); }; // --- Pull-to-refresh (mobile only) --- @@ -368,10 +589,8 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { if (pullDistance > PULL_THRESHOLD && !isRefreshing && !loading) { setIsRefreshing(true); setPullDistance(0); - // Trigger the existing refresh mechanism (keeps current rows until new data lands) - setPage(1); - setIsListFading(true); - setRefreshTrigger(prev => prev + 1); + // Same one-shot path as the refresh button (also clears staged pill entries) + handleRefresh(); // Reset refreshing indicator after a short delay setTimeout(() => setIsRefreshing(false), 1200); } else { @@ -379,9 +598,6 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { } }, [pullDistance, isRefreshing, loading, isMobile]); - const logsEnabled = - activeProfile?.settings?.logs.enabled !== false; // default to true if undefined - return (
@@ -390,30 +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 && ( + + )} +
@@ -426,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} + />
@@ -489,6 +745,9 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { blocklistNames={blocklistNames} serviceNames={serviceNames} onExpand={dismissExpandHint} + expanded={expandedKeys.has(group.identity)} + onToggleExpanded={() => toggleCardExpanded(group.identity)} + animateEntry={freshIdentities.has(group.identity)} /> ); })} @@ -507,9 +766,27 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { ))}
)} - {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 && (