diff --git a/conf/defaults.ini b/conf/defaults.ini index b8401e48bb2..03aad2655a1 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -865,6 +865,11 @@ max_annotations_to_keep = # Enable the Explore section enabled = true +#################################### Query History ############################# +[query_history] +# Enable the Query history +enabled = false + #################################### Internal Grafana Metrics ############ # Metrics available at HTTP API Url /metrics [metrics] diff --git a/conf/sample.ini b/conf/sample.ini index 10d7d9145dc..2a7355fea4d 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -848,6 +848,11 @@ # Enable the Explore section ;enabled = true +#################################### Query History ############################# +[query_history] +# Enable the Query history +;enabled = false + #################################### Internal Grafana Metrics ########################## # Metrics available at HTTP API Url /metrics [metrics] diff --git a/docs/sources/http_api/query_history.md b/docs/sources/http_api/query_history.md new file mode 100644 index 00000000000..44a7f21d2cb --- /dev/null +++ b/docs/sources/http_api/query_history.md @@ -0,0 +1,59 @@ ++++ +title = "Query History HTTP API " +description = "Grafana Query History HTTP API" +keywords = ["grafana", "http", "documentation", "api", "queryHistory"] +aliases = ["/docs/grafana/latest/http_api/query_history/"] ++++ + +# Query history API + +This API can be used to add queries to Query history. It requires that the user is logged in and that Query history feature is enabled in config file. + +## Add query to Query history + +`POST /api/query-history` + +Adds query to query history. + +**Example request:** + +```http +POST /api/query-history HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +{ + "dataSourceUid": "PE1C5CBDA0504A6A3", + "queries": [ + { + "refId": "A", + "key": "Q-87fed8e3-62ba-4eb2-8d2a-4129979bb4de-0", + "scenarioId": "csv_content", + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + } + } +] +} +``` + +JSON body schema: + +- **datasourceUid** – Data source uid. +- **queries** – JSON of query or queries. + +**Example response:** + +```http +HTTP/1.1 200 +Content-Type: application/json +{ + "message": "Query successfully added to query history", +} +``` + +Status codes: + +- **200** – OK +- **500** – Errors (invalid JSON, missing or invalid fields) diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 6b5a6f10c8c..b7538a2dda2 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -214,6 +214,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "verifyEmailEnabled": setting.VerifyEmailEnabled, "sigV4AuthEnabled": setting.SigV4AuthEnabled, "exploreEnabled": setting.ExploreEnabled, + "queryHistoryEnabled": hs.Cfg.QueryHistoryEnabled, "googleAnalyticsId": setting.GoogleAnalyticsId, "rudderstackWriteKey": setting.RudderstackWriteKey, "rudderstackDataPlaneUrl": setting.RudderstackDataPlaneUrl, diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 3124178c74e..e9cb7e69dfa 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -47,6 +47,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/query" + "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/schemaloader" @@ -100,6 +101,7 @@ type HTTPServer struct { pluginErrorResolver plugins.ErrorResolver SearchService *search.SearchService ShortURLService shorturls.Service + QueryHistoryService queryhistory.Service Live *live.GrafanaLive LivePushGateway *pushhttp.Gateway ThumbService thumbs.Service @@ -137,8 +139,8 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, pluginClient plugins.Client, pluginErrorResolver plugins.ErrorResolver, settingsProvider setting.Provider, dataSourceCache datasources.CacheService, userTokenService models.UserTokenService, - cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, thumbService thumbs.Service, - remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService, + cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, queryHistoryService queryhistory.Service, + thumbService thumbs.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService, loginService login.Service, accessControl accesscontrol.AccessControl, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService, live *live.GrafanaLive, livePushGateway *pushhttp.Gateway, plugCtxProvider *plugincontext.Provider, @@ -175,6 +177,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi AuthTokenService: userTokenService, cleanUpService: cleanUpService, ShortURLService: shortURLService, + QueryHistoryService: queryHistoryService, Features: features, ThumbService: thumbService, RemoteCacheService: remoteCache, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index c54cdc58089..5f271ac1c89 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -53,6 +53,7 @@ import ( "github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/query" + "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/schemaloader" @@ -133,6 +134,8 @@ var wireBasicSet = wire.NewSet( cleanup.ProvideService, shorturls.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturls.ShortURLService)), + queryhistory.ProvideService, + wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), quota.ProvideService, remotecache.ProvideService, loginservice.ProvideService, diff --git a/pkg/services/queryhistory/api.go b/pkg/services/queryhistory/api.go new file mode 100644 index 00000000000..929b99feb32 --- /dev/null +++ b/pkg/services/queryhistory/api.go @@ -0,0 +1,31 @@ +package queryhistory + +import ( + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/web" +) + +func (s *QueryHistoryService) registerAPIEndpoints() { + s.RouteRegister.Group("/api/query-history", func(entities routing.RouteRegister) { + entities.Post("/", middleware.ReqSignedIn, routing.Wrap(s.createHandler)) + }) +} + +func (s *QueryHistoryService) createHandler(c *models.ReqContext) response.Response { + cmd := CreateQueryInQueryHistoryCommand{} + if err := web.Bind(c.Req, &cmd); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + err := s.CreateQueryInQueryHistory(c.Req.Context(), c.SignedInUser, cmd) + if err != nil { + return response.Error(500, "Failed to create query history", err) + } + + return response.Success("Query successfully added to query history") +} diff --git a/pkg/services/queryhistory/database.go b/pkg/services/queryhistory/database.go new file mode 100644 index 00000000000..a7ed8951268 --- /dev/null +++ b/pkg/services/queryhistory/database.go @@ -0,0 +1,32 @@ +package queryhistory + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/util" +) + +func (s QueryHistoryService) createQuery(ctx context.Context, user *models.SignedInUser, cmd CreateQueryInQueryHistoryCommand) error { + queryHistory := QueryHistory{ + OrgId: user.OrgId, + Uid: util.GenerateShortUID(), + Queries: cmd.Queries, + DatasourceUid: cmd.DatasourceUid, + CreatedBy: user.UserId, + CreatedAt: time.Now().Unix(), + Comment: "", + } + + err := s.SQLStore.WithDbSession(ctx, func(session *sqlstore.DBSession) error { + _, err := session.Insert(&queryHistory) + return err + }) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/services/queryhistory/models.go b/pkg/services/queryhistory/models.go new file mode 100644 index 00000000000..21490529da7 --- /dev/null +++ b/pkg/services/queryhistory/models.go @@ -0,0 +1,21 @@ +package queryhistory + +import ( + "github.com/grafana/grafana/pkg/components/simplejson" +) + +type QueryHistory struct { + Id int64 `json:"id"` + Uid string `json:"uid"` + DatasourceUid string `json:"datasourceUid"` + OrgId int64 `json:"orgId"` + CreatedBy int64 `json:"createdBy"` + CreatedAt int64 `json:"createdAt"` + Comment string `json:"comment"` + Queries *simplejson.Json `json:"queries"` +} + +type CreateQueryInQueryHistoryCommand struct { + DatasourceUid string `json:"datasourceUid"` + Queries *simplejson.Json `json:"queries"` +} diff --git a/pkg/services/queryhistory/queryhistory.go b/pkg/services/queryhistory/queryhistory.go new file mode 100644 index 00000000000..7614121205b --- /dev/null +++ b/pkg/services/queryhistory/queryhistory.go @@ -0,0 +1,42 @@ +package queryhistory + +import ( + "context" + + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, routeRegister routing.RouteRegister) *QueryHistoryService { + s := &QueryHistoryService{ + SQLStore: sqlStore, + Cfg: cfg, + RouteRegister: routeRegister, + log: log.New("query-history"), + } + + // Register routes only when query history is enabled + if s.Cfg.QueryHistoryEnabled { + s.registerAPIEndpoints() + } + + return s +} + +type Service interface { + CreateQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, cmd CreateQueryInQueryHistoryCommand) error +} + +type QueryHistoryService struct { + SQLStore *sqlstore.SQLStore + Cfg *setting.Cfg + RouteRegister routing.RouteRegister + log log.Logger +} + +func (s QueryHistoryService) CreateQueryInQueryHistory(ctx context.Context, user *models.SignedInUser, cmd CreateQueryInQueryHistoryCommand) error { + return s.createQuery(ctx, user, cmd) +} diff --git a/pkg/services/queryhistory/queryhistory_create_test.go b/pkg/services/queryhistory/queryhistory_create_test.go new file mode 100644 index 00000000000..588bc572e26 --- /dev/null +++ b/pkg/services/queryhistory/queryhistory_create_test.go @@ -0,0 +1,23 @@ +package queryhistory + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/stretchr/testify/require" +) + +func TestCreateQueryInQueryHistory(t *testing.T) { + testScenario(t, "When users tries to create query in query history it should succeed", + func(t *testing.T, sc scenarioContext) { + command := CreateQueryInQueryHistoryCommand{ + DatasourceUid: "NCzh67i", + Queries: simplejson.NewFromAny(map[string]interface{}{ + "expr": "test", + }), + } + sc.reqContext.Req.Body = mockRequestBody(command) + resp := sc.service.createHandler(sc.reqContext) + require.Equal(t, 200, resp.Status()) + }) +} diff --git a/pkg/services/queryhistory/queryhistory_test.go b/pkg/services/queryhistory/queryhistory_test.go new file mode 100644 index 00000000000..599526fb89b --- /dev/null +++ b/pkg/services/queryhistory/queryhistory_test.go @@ -0,0 +1,77 @@ +package queryhistory + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" +) + +var ( + testOrgID = int64(1) + testUserID = int64(1) +) + +type scenarioContext struct { + ctx *web.Context + service *QueryHistoryService + reqContext *models.ReqContext + sqlStore *sqlstore.SQLStore +} + +func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioContext)) { + t.Helper() + + t.Run(desc, func(t *testing.T) { + ctx := web.Context{Req: &http.Request{}} + sqlStore := sqlstore.InitTestDB(t) + service := QueryHistoryService{ + Cfg: setting.NewCfg(), + SQLStore: sqlStore, + } + + service.Cfg.QueryHistoryEnabled = true + + user := models.SignedInUser{ + UserId: testUserID, + Name: "Signed In User", + Login: "signed_in_user", + Email: "signed.in.user@test.com", + OrgId: testOrgID, + OrgRole: models.ROLE_VIEWER, + LastSeenAt: time.Now(), + } + + _, err := sqlStore.CreateUser(context.Background(), models.CreateUserCommand{ + Email: "signed.in.user@test.com", + Name: "Signed In User", + Login: "signed_in_user", + }) + require.NoError(t, err) + + sc := scenarioContext{ + ctx: &ctx, + service: &service, + sqlStore: sqlStore, + reqContext: &models.ReqContext{ + Context: &ctx, + SignedInUser: &user, + }, + } + fn(t, sc) + }) +} + +func mockRequestBody(v interface{}) io.ReadCloser { + b, _ := json.Marshal(v) + return io.NopCloser(bytes.NewReader(b)) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 86f79a5beba..efedc707e31 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -68,6 +68,7 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { addKVStoreMigrations(mg) ualert.AddDashboardUIDPanelIDMigration(mg) accesscontrol.AddMigration(mg) + addQueryHistoryMigrations(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/query_history_mig.go b/pkg/services/sqlstore/migrations/query_history_mig.go new file mode 100644 index 00000000000..9bc7097c06e --- /dev/null +++ b/pkg/services/sqlstore/migrations/query_history_mig.go @@ -0,0 +1,28 @@ +package migrations + +import ( + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func addQueryHistoryMigrations(mg *Migrator) { + queryHistoryV1 := Table{ + Name: "query_history", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, Nullable: false, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: false}, + {Name: "org_id", Type: DB_BigInt, Nullable: false}, + {Name: "datasource_uid", Type: DB_NVarchar, Length: 40, Nullable: false}, + {Name: "created_by", Type: DB_Int, Nullable: false}, + {Name: "created_at", Type: DB_Int, Nullable: false}, + {Name: "comment", Type: DB_Text, Nullable: false}, + {Name: "queries", Type: DB_Text, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"org_id", "created_by", "datasource_uid"}}, + }, + } + + mg.AddMigration("create query_history table v1", NewAddTableMigration(queryHistoryV1)) + + mg.AddMigration("add index query_history.org_id-created_by-datasource_uid", NewAddIndexMigration(queryHistoryV1, queryHistoryV1.Indices[0])) +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 1b9f48c870a..713bfc31d3f 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -429,6 +429,9 @@ type Cfg struct { // Unified Alerting UnifiedAlerting UnifiedAlertingSettings + + // Query history + QueryHistoryEnabled bool } type CommandLineArgs struct { @@ -940,6 +943,9 @@ func (cfg *Cfg) Load(args CommandLineArgs) error { explore := iniFile.Section("explore") ExploreEnabled = explore.Key("enabled").MustBool(true) + queryHistory := iniFile.Section("query_history") + cfg.QueryHistoryEnabled = queryHistory.Key("enabled").MustBool(false) + panelsSection := iniFile.Section("panels") cfg.DisableSanitizeHtml = panelsSection.Key("disable_sanitize_html").MustBool(false)