From b9ae0c98b6a598ebda65e8e42f4c64ed78e12f37 Mon Sep 17 00:00:00 2001 From: Christian Simon Date: Mon, 1 Dec 2025 13:27:44 +0000 Subject: [PATCH] Persist maps to SQL store --- pkg/api/api.go | 3 + pkg/api/exploremap.go | 227 ++++++++++++ pkg/api/http_server.go | 5 +- pkg/server/wire.go | 2 + pkg/server/wire_gen.go | 9 +- .../exploremap/exploremapimpl/service.go | 67 ++++ .../exploremap/exploremapimpl/store.go | 15 + .../exploremap/exploremapimpl/xorm_store.go | 156 ++++++++ pkg/services/exploremap/model.go | 76 ++++ pkg/services/exploremap/service.go | 13 + pkg/services/navtree/navtreeimpl/navtree.go | 8 +- .../sqlstore/migrations/explore_map_mig.go | 30 ++ .../sqlstore/migrations/migrations.go | 1 + .../explore-map/ExploreMapListPage.tsx | 342 ++++++++++++++++++ .../features/explore-map/ExploreMapPage.tsx | 26 +- .../features/explore-map/api/exploreMapApi.ts | 77 ++++ .../components/ExploreMapToolbar.tsx | 147 +++++++- .../explore-map/hooks/useCanvasPersistence.ts | 257 ++++++++----- .../explore-map/state/exploreMapSlice.ts | 16 + .../app/features/explore-map/state/types.ts | 4 + public/app/routes/routes.tsx | 12 +- 21 files changed, 1378 insertions(+), 115 deletions(-) create mode 100644 pkg/api/exploremap.go create mode 100644 pkg/services/exploremap/exploremapimpl/service.go create mode 100644 pkg/services/exploremap/exploremapimpl/store.go create mode 100644 pkg/services/exploremap/exploremapimpl/xorm_store.go create mode 100644 pkg/services/exploremap/model.go create mode 100644 pkg/services/exploremap/service.go create mode 100644 pkg/services/sqlstore/migrations/explore_map_mig.go create mode 100644 public/app/features/explore-map/ExploreMapListPage.tsx create mode 100644 public/app/features/explore-map/api/exploreMapApi.ts diff --git a/pkg/api/api.go b/pkg/api/api.go index 88f432c206c..d9da73c70cd 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -502,6 +502,9 @@ func (hs *HTTPServer) registerRoutes() { // Playlist hs.registerPlaylistAPI(apiRoute) + // Explore Maps + hs.registerExploreMapAPI(apiRoute, hs.exploreMapService) + // Search apiRoute.Get("/search/sorting", routing.Wrap(hs.ListSortOptions)) apiRoute.Get("/search/", routing.Wrap(hs.Search)) diff --git a/pkg/api/exploremap.go b/pkg/api/exploremap.go new file mode 100644 index 00000000000..5ae26cd46a3 --- /dev/null +++ b/pkg/api/exploremap.go @@ -0,0 +1,227 @@ +package api + +import ( + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/exploremap" + "github.com/grafana/grafana/pkg/web" +) + +func (hs *HTTPServer) registerExploreMapAPI(apiRoute routing.RouteRegister, exploreMapService exploremap.Service) { + apiRoute.Group("/explore-maps", func(exploreMapRoute routing.RouteRegister) { + exploreMapRoute.Get("/", routing.Wrap(hs.listExploreMaps)) + exploreMapRoute.Post("/", routing.Wrap(hs.createExploreMap)) + exploreMapRoute.Get("/:uid", routing.Wrap(hs.getExploreMap)) + exploreMapRoute.Put("/:uid", routing.Wrap(hs.updateExploreMap)) + exploreMapRoute.Delete("/:uid", routing.Wrap(hs.deleteExploreMap)) + }) + hs.exploreMapService = exploreMapService +} + +// swagger:parameters listExploreMaps +type ListExploreMapsParams struct { + // in:query + // required:false + Limit int `json:"limit"` +} + +// swagger:parameters getExploreMap +type GetExploreMapParams struct { + // in:path + // required:true + UID string `json:"uid"` +} + +// swagger:parameters deleteExploreMap +type DeleteExploreMapParams struct { + // in:path + // required:true + UID string `json:"uid"` +} + +// swagger:parameters updateExploreMap +type UpdateExploreMapParams struct { + // in:body + // required:true + Body exploremap.UpdateExploreMapCommand + // in:path + // required:true + UID string `json:"uid"` +} + +// swagger:parameters createExploreMap +type CreateExploreMapParams struct { + // in:body + // required:true + Body exploremap.CreateExploreMapCommand +} + +// swagger:response listExploreMapsResponse +type ListExploreMapsResponse struct { + // The response message + // in: body + Body exploremap.ExploreMaps `json:"body"` +} + +// swagger:response getExploreMapResponse +type GetExploreMapResponse struct { + // The response message + // in: body + Body *exploremap.ExploreMapDTO `json:"body"` +} + +// swagger:response updateExploreMapResponse +type UpdateExploreMapResponse struct { + // The response message + // in: body + Body *exploremap.ExploreMapDTO `json:"body"` +} + +// swagger:response createExploreMapResponse +type CreateExploreMapResponse struct { + // The response message + // in: body + Body *exploremap.ExploreMap `json:"body"` +} + +// swagger:route GET /explore-maps explore-maps listExploreMaps +// +// Get explore maps. +// +// Responses: +// 200: listExploreMapsResponse +// 500: internalServerError +func (hs *HTTPServer) listExploreMaps(c *contextmodel.ReqContext) response.Response { + query := &exploremap.GetExploreMapsQuery{ + OrgID: c.SignedInUser.GetOrgID(), + Limit: c.QueryInt("limit"), + } + + if query.Limit == 0 { + query.Limit = 100 + } + + maps, err := hs.exploreMapService.List(c.Req.Context(), query) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to get explore maps", err) + } + + return response.JSON(http.StatusOK, maps) +} + +// swagger:route GET /explore-maps/{uid} explore-maps getExploreMap +// +// Get explore map. +// +// Responses: +// 200: getExploreMapResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (hs *HTTPServer) getExploreMap(c *contextmodel.ReqContext) response.Response { + uid := web.Params(c.Req)[":uid"] + query := &exploremap.GetExploreMapByUIDQuery{ + UID: uid, + OrgID: c.SignedInUser.GetOrgID(), + } + + m, err := hs.exploreMapService.Get(c.Req.Context(), query) + if err != nil { + if err == exploremap.ErrExploreMapNotFound { + return response.Error(http.StatusNotFound, "Explore map not found", err) + } + return response.Error(http.StatusInternalServerError, "Failed to get explore map", err) + } + + return response.JSON(http.StatusOK, m) +} + +// swagger:route POST /explore-maps explore-maps createExploreMap +// +// Create explore map. +// +// Responses: +// 200: createExploreMapResponse +// 401: unauthorisedError +// 403: forbiddenError +// 500: internalServerError +func (hs *HTTPServer) createExploreMap(c *contextmodel.ReqContext) response.Response { + cmd := exploremap.CreateExploreMapCommand{} + if err := web.Bind(c.Req, &cmd); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + cmd.OrgID = c.SignedInUser.GetOrgID() + cmd.CreatedBy = c.SignedInUser.UserID + + m, err := hs.exploreMapService.Create(c.Req.Context(), &cmd) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to create explore map", err) + } + + return response.JSON(http.StatusOK, m) +} + +// swagger:route PUT /explore-maps/{uid} explore-maps updateExploreMap +// +// Update explore map. +// +// Responses: +// 200: updateExploreMapResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (hs *HTTPServer) updateExploreMap(c *contextmodel.ReqContext) response.Response { + uid := web.Params(c.Req)[":uid"] + cmd := exploremap.UpdateExploreMapCommand{} + if err := web.Bind(c.Req, &cmd); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + cmd.UID = uid + cmd.OrgID = c.SignedInUser.GetOrgID() + cmd.UpdatedBy = c.SignedInUser.UserID + + m, err := hs.exploreMapService.Update(c.Req.Context(), &cmd) + if err != nil { + if err == exploremap.ErrExploreMapNotFound { + return response.Error(http.StatusNotFound, "Explore map not found", err) + } + return response.Error(http.StatusInternalServerError, "Failed to update explore map", err) + } + + return response.JSON(http.StatusOK, m) +} + +// swagger:route DELETE /explore-maps/{uid} explore-maps deleteExploreMap +// +// Delete explore map. +// +// Responses: +// 200: okResponse +// 401: unauthorisedError +// 403: forbiddenError +// 404: notFoundError +// 500: internalServerError +func (hs *HTTPServer) deleteExploreMap(c *contextmodel.ReqContext) response.Response { + uid := web.Params(c.Req)[":uid"] + cmd := &exploremap.DeleteExploreMapCommand{ + UID: uid, + OrgID: c.SignedInUser.GetOrgID(), + } + + err := hs.exploreMapService.Delete(c.Req.Context(), cmd) + if err != nil { + if err == exploremap.ErrExploreMapNotFound { + return response.Error(http.StatusNotFound, "Explore map not found", err) + } + return response.Error(http.StatusInternalServerError, "Failed to delete explore map", err) + } + + return response.JSON(http.StatusOK, map[string]string{"message": "Explore map deleted"}) +} diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index f2ac32a80c6..42a8a9ce7e7 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -80,6 +80,7 @@ import ( "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/services/exploremap" "github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" @@ -198,6 +199,7 @@ type HTTPServer struct { PublicDashboardsApi *publicdashboardsApi.Api starService star.Service playlistService playlist.Service + exploreMapService exploremap.Service apiKeyService apikey.Service kvStore kvstore.KVStore pluginsCDNService *pluginscdn.Service @@ -265,7 +267,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi folderPermissionsService accesscontrol.FolderPermissionsService, dashboardPermissionsService accesscontrol.DashboardPermissionsService, dashboardVersionService dashver.Service, starService star.Service, csrfService csrf.Service, managedPlugins managedplugins.Manager, - playlistService playlist.Service, apiKeyService apikey.Service, kvStore kvstore.KVStore, + playlistService playlist.Service, exploreMapService exploremap.Service, apiKeyService apikey.Service, kvStore kvstore.KVStore, secretsMigrator secrets.Migrator, secretsService secrets.Service, secretMigrationProvider spm.SecretMigrationProvider, secretsStore secretsKV.SecretsKVStore, publicDashboardsApi *publicdashboardsApi.Api, userService user.Service, tempUserService tempUser.Service, @@ -353,6 +355,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi dashboardVersionService: dashboardVersionService, starService: starService, playlistService: playlistService, + exploreMapService: exploreMapService, apiKeyService: apiKeyService, kvStore: kvStore, PublicDashboardsApi: publicDashboardsApi, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 0d4bb10c0b5..0c9216d78ec 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -120,6 +120,7 @@ import ( "github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/playlist/playlistimpl" + "github.com/grafana/grafana/pkg/services/exploremap/exploremapimpl" "github.com/grafana/grafana/pkg/services/plugindashboards" plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service" "github.com/grafana/grafana/pkg/services/pluginsintegration" @@ -374,6 +375,7 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, + exploremapimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, publicdashboardsService.ProvideService, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 75341e77f78..2243b6f27d4 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -137,6 +137,7 @@ import ( encryption2 "github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/encryption/provider" service2 "github.com/grafana/grafana/pkg/services/encryption/service" + "github.com/grafana/grafana/pkg/services/exploremap/exploremapimpl" "github.com/grafana/grafana/pkg/services/extsvcauth" registry2 "github.com/grafana/grafana/pkg/services/extsvcauth/registry" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -726,6 +727,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } csrfCSRF := csrf.ProvideCSRFFilter(cfg) playlistService := playlistimpl.ProvideService(sqlStore, tracingService) + exploremapService := exploremapimpl.ProvideService(sqlStore, tracingService) secretsMigrator := migrator2.ProvideSecretsMigrator(serviceService, secretsService, sqlStore, ossImpl, featureToggles) dataSourceSecretMigrationService := migrations3.ProvideDataSourceMigrationService(service15, kvStore, featureToggles) secretMigrationProviderImpl := migrations3.ProvideSecretMigrationProvider(serverLockService, dataSourceSecretMigrationService) @@ -755,7 +757,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer, tracer) verifier := userimpl.ProvideVerifier(cfg, userService, tempuserService, notificationService, idimplService) - httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service14, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service15, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationService, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service13, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, managedpluginsNoop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokenService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl) + httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service14, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service15, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationService, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service13, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, managedpluginsNoop, playlistService, exploremapService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokenService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl) if err != nil { return nil, err } @@ -1376,6 +1378,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } csrfCSRF := csrf.ProvideCSRFFilter(cfg) playlistService := playlistimpl.ProvideService(sqlStore, tracingService) + exploremapService := exploremapimpl.ProvideService(sqlStore, tracingService) secretsMigrator := migrator2.ProvideSecretsMigrator(serviceService, secretsService, sqlStore, ossImpl, featureToggles) dataSourceSecretMigrationService := migrations3.ProvideDataSourceMigrationService(service15, kvStore, featureToggles) secretMigrationProviderImpl := migrations3.ProvideSecretMigrationProvider(serverLockService, dataSourceSecretMigrationService) @@ -1405,7 +1408,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } idimplService := idimpl.ProvideService(cfg, localSigner, remoteCache, authnService, registerer, tracer) verifier := userimpl.ProvideVerifier(cfg, userService, tempuserService, notificationServiceMock, idimplService) - httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service14, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service15, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationServiceMock, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service13, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, managedpluginsNoop, playlistService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokentestService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl) + httpServer, err := api.ProvideHTTPServer(apiOpts, cfg, routeRegisterImpl, inProcBus, renderingService, ossLicensingService, hooksService, cacheService, sqlStore, ossDataSourceRequestValidator, pluginstoreService, service14, pluginstoreService, middlewareHandler, pluginerrsStore, pluginInstaller, ossImpl, cacheServiceImpl, userAuthTokenService, cleanUpService, shortURLService, queryHistoryService, correlationsService, remoteCache, provisioningServiceImpl, accessControl, dataSourceProxyService, searchSearchService, grafanaLive, gateway, plugincontextProvider, contexthandlerContextHandler, logger, featureToggles, alertNG, libraryPanelService, libraryElementService, quotaService, socialService, tracingService, serviceService, grafanaService, pluginsService, ossService, service15, queryServiceImpl, filestoreService, serviceAccountsProxy, pluginassetsService, authinfoimplService, storageService, notificationServiceMock, dashboardService, dashboardProvisioningService, folderimplService, ossProvider, serviceImpl, service13, avatarCacheServer, prefService, folderPermissionsService, dashboardPermissionsService, dashverService, starService, csrfCSRF, managedpluginsNoop, playlistService, exploremapService, apikeyService, kvStore, secretsMigrator, secretsService, secretMigrationProviderImpl, secretsKVStore, apiApi, userService, tempuserService, loginattemptimplService, orgService, deletionService, teamService, acimplService, navtreeService, repositoryImpl, tagimplService, searchHTTPService, oauthtokentestService, statsService, authnService, pluginscdnService, gatherer, apiAPI, registerer, eventualRestConfigProvider, anonDeviceService, verifier, preinstallImpl) if err != nil { return nil, err } @@ -1776,7 +1779,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, exploremapimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/services/exploremap/exploremapimpl/service.go b/pkg/services/exploremap/exploremapimpl/service.go new file mode 100644 index 00000000000..226a74b6682 --- /dev/null +++ b/pkg/services/exploremap/exploremapimpl/service.go @@ -0,0 +1,67 @@ +package exploremapimpl + +import ( + "context" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/exploremap" +) + +type Service struct { + store store + tracer tracing.Tracer +} + +var _ exploremap.Service = &Service{} + +func ProvideService(db db.DB, tracer tracing.Tracer) exploremap.Service { + return &Service{ + tracer: tracer, + store: &sqlStore{ + db: db, + }, + } +} + +func (s *Service) Create(ctx context.Context, cmd *exploremap.CreateExploreMapCommand) (*exploremap.ExploreMap, error) { + ctx, span := s.tracer.Start(ctx, "exploremap.Create") + defer span.End() + return s.store.Insert(ctx, cmd) +} + +func (s *Service) Update(ctx context.Context, cmd *exploremap.UpdateExploreMapCommand) (*exploremap.ExploreMapDTO, error) { + ctx, span := s.tracer.Start(ctx, "exploremap.Update") + defer span.End() + return s.store.Update(ctx, cmd) +} + +func (s *Service) Get(ctx context.Context, q *exploremap.GetExploreMapByUIDQuery) (*exploremap.ExploreMapDTO, error) { + ctx, span := s.tracer.Start(ctx, "exploremap.Get") + defer span.End() + v, err := s.store.Get(ctx, q) + if err != nil { + return nil, err + } + return &exploremap.ExploreMapDTO{ + UID: v.UID, + Title: v.Title, + Data: v.Data, + CreatedBy: v.CreatedBy, + UpdatedBy: v.UpdatedBy, + CreatedAt: v.CreatedAt, + UpdatedAt: v.UpdatedAt, + }, nil +} + +func (s *Service) List(ctx context.Context, q *exploremap.GetExploreMapsQuery) (exploremap.ExploreMaps, error) { + ctx, span := s.tracer.Start(ctx, "exploremap.List") + defer span.End() + return s.store.List(ctx, q) +} + +func (s *Service) Delete(ctx context.Context, cmd *exploremap.DeleteExploreMapCommand) error { + ctx, span := s.tracer.Start(ctx, "exploremap.Delete") + defer span.End() + return s.store.Delete(ctx, cmd) +} diff --git a/pkg/services/exploremap/exploremapimpl/store.go b/pkg/services/exploremap/exploremapimpl/store.go new file mode 100644 index 00000000000..d4873971f89 --- /dev/null +++ b/pkg/services/exploremap/exploremapimpl/store.go @@ -0,0 +1,15 @@ +package exploremapimpl + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/exploremap" +) + +type store interface { + Insert(ctx context.Context, cmd *exploremap.CreateExploreMapCommand) (*exploremap.ExploreMap, error) + Update(ctx context.Context, cmd *exploremap.UpdateExploreMapCommand) (*exploremap.ExploreMapDTO, error) + Get(ctx context.Context, query *exploremap.GetExploreMapByUIDQuery) (*exploremap.ExploreMap, error) + List(ctx context.Context, query *exploremap.GetExploreMapsQuery) (exploremap.ExploreMaps, error) + Delete(ctx context.Context, cmd *exploremap.DeleteExploreMapCommand) error +} diff --git a/pkg/services/exploremap/exploremapimpl/xorm_store.go b/pkg/services/exploremap/exploremapimpl/xorm_store.go new file mode 100644 index 00000000000..e90691a2a55 --- /dev/null +++ b/pkg/services/exploremap/exploremapimpl/xorm_store.go @@ -0,0 +1,156 @@ +package exploremapimpl + +import ( + "context" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/exploremap" + "github.com/grafana/grafana/pkg/util" +) + +type sqlStore struct { + db db.DB +} + +const MAX_EXPLORE_MAPS = 100 + +var _ store = &sqlStore{} + +func (s *sqlStore) Insert(ctx context.Context, cmd *exploremap.CreateExploreMapCommand) (*exploremap.ExploreMap, error) { + m := exploremap.ExploreMap{} + if cmd.UID == "" { + cmd.UID = util.GenerateShortUID() + } else { + err := util.ValidateUID(cmd.UID) + if err != nil { + return nil, err + } + } + + err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + count, err := sess.SQL("SELECT COUNT(*) FROM explore_map WHERE org_id = ?", cmd.OrgID).Count() + if err != nil { + return err + } + if count > MAX_EXPLORE_MAPS { + return fmt.Errorf("too many explore maps exist (%d > %d)", count, MAX_EXPLORE_MAPS) + } + + now := time.Now() + m = exploremap.ExploreMap{ + UID: cmd.UID, + Title: cmd.Title, + Data: cmd.Data, + OrgID: cmd.OrgID, + CreatedBy: cmd.CreatedBy, + UpdatedBy: cmd.CreatedBy, + CreatedAt: now, + UpdatedAt: now, + } + + _, err = sess.Insert(&m) + return err + }) + return &m, err +} + +func (s *sqlStore) Update(ctx context.Context, cmd *exploremap.UpdateExploreMapCommand) (*exploremap.ExploreMapDTO, error) { + dto := exploremap.ExploreMapDTO{} + err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + existing := exploremap.ExploreMap{UID: cmd.UID, OrgID: cmd.OrgID} + has, err := sess.Get(&existing) + if err != nil { + return err + } + if !has { + return exploremap.ErrExploreMapNotFound + } + + m := exploremap.ExploreMap{ + ID: existing.ID, + UID: cmd.UID, + Title: cmd.Title, + Data: cmd.Data, + OrgID: cmd.OrgID, + CreatedBy: existing.CreatedBy, + UpdatedBy: cmd.UpdatedBy, + CreatedAt: existing.CreatedAt, + UpdatedAt: time.Now(), + } + + _, err = sess.Where("id=?", m.ID).Cols("title", "data", "updated_by", "updated_at").Update(&m) + if err != nil { + return err + } + + dto = exploremap.ExploreMapDTO{ + UID: m.UID, + Title: m.Title, + Data: m.Data, + CreatedBy: m.CreatedBy, + UpdatedBy: m.UpdatedBy, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } + + return nil + }) + return &dto, err +} + +func (s *sqlStore) Get(ctx context.Context, query *exploremap.GetExploreMapByUIDQuery) (*exploremap.ExploreMap, error) { + if query.UID == "" || query.OrgID == 0 { + return nil, exploremap.ErrCommandValidationFailed + } + + m := exploremap.ExploreMap{} + err := s.db.WithDbSession(ctx, func(sess *db.Session) error { + m = exploremap.ExploreMap{UID: query.UID, OrgID: query.OrgID} + exists, err := sess.Get(&m) + if !exists { + return exploremap.ErrExploreMapNotFound + } + return err + }) + return &m, err +} + +func (s *sqlStore) Delete(ctx context.Context, cmd *exploremap.DeleteExploreMapCommand) error { + if cmd.UID == "" || cmd.OrgID == 0 { + return exploremap.ErrCommandValidationFailed + } + + return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + m := exploremap.ExploreMap{UID: cmd.UID, OrgID: cmd.OrgID} + exists, err := sess.Get(&m) + if err != nil { + return err + } + if !exists { + return exploremap.ErrExploreMapNotFound + } + + var rawSQL = "DELETE FROM explore_map WHERE uid = ? and org_id = ?" + _, err = sess.Exec(rawSQL, cmd.UID, cmd.OrgID) + return err + }) +} + +func (s *sqlStore) List(ctx context.Context, query *exploremap.GetExploreMapsQuery) (exploremap.ExploreMaps, error) { + maps := make(exploremap.ExploreMaps, 0) + if query.OrgID == 0 { + return maps, exploremap.ErrCommandValidationFailed + } + + if query.Limit > MAX_EXPLORE_MAPS || query.Limit < 1 { + query.Limit = MAX_EXPLORE_MAPS + } + + err := s.db.WithDbSession(ctx, func(dbSess *db.Session) error { + sess := dbSess.Limit(query.Limit).Where("org_id = ?", query.OrgID).OrderBy("updated_at DESC") + return sess.Find(&maps) + }) + return maps, err +} diff --git a/pkg/services/exploremap/model.go b/pkg/services/exploremap/model.go new file mode 100644 index 00000000000..9d5fabe9f31 --- /dev/null +++ b/pkg/services/exploremap/model.go @@ -0,0 +1,76 @@ +package exploremap + +import ( + "errors" + "time" +) + +// Typed errors +var ( + ErrExploreMapNotFound = errors.New("explore map not found") + ErrCommandValidationFailed = errors.New("command missing required fields") +) + +// ExploreMap model +type ExploreMap struct { + ID int64 `json:"id" xorm:"pk autoincr 'id'"` + UID string `json:"uid" xorm:"uid"` + Title string `json:"title" xorm:"title"` + Data string `json:"data" xorm:"data"` // JSON-encoded ExploreMapState + OrgID int64 `json:"-" xorm:"org_id"` + CreatedBy int64 `json:"createdBy" xorm:"created_by"` + UpdatedBy int64 `json:"updatedBy" xorm:"updated_by"` + CreatedAt time.Time `json:"createdAt" xorm:"created_at"` + UpdatedAt time.Time `json:"updatedAt" xorm:"updated_at"` +} + +type ExploreMapDTO struct { + UID string `json:"uid"` + Title string `json:"title"` + Data string `json:"data"` + CreatedBy int64 `json:"createdBy"` + UpdatedBy int64 `json:"updatedBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ExploreMaps []*ExploreMap + +// +// COMMANDS +// + +type CreateExploreMapCommand struct { + UID string `json:"uid"` + Title string `json:"title" binding:"Required"` + Data string `json:"data"` + OrgID int64 `json:"-"` + CreatedBy int64 `json:"-"` +} + +type UpdateExploreMapCommand struct { + UID string `json:"uid"` + Title string `json:"title"` + Data string `json:"data"` + OrgID int64 `json:"-"` + UpdatedBy int64 `json:"-"` +} + +type DeleteExploreMapCommand struct { + UID string + OrgID int64 +} + +// +// QUERIES +// + +type GetExploreMapsQuery struct { + OrgID int64 + Limit int +} + +type GetExploreMapByUIDQuery struct { + UID string + OrgID int64 +} diff --git a/pkg/services/exploremap/service.go b/pkg/services/exploremap/service.go new file mode 100644 index 00000000000..65db4387b35 --- /dev/null +++ b/pkg/services/exploremap/service.go @@ -0,0 +1,13 @@ +package exploremap + +import ( + "context" +) + +type Service interface { + Create(context.Context, *CreateExploreMapCommand) (*ExploreMap, error) + Update(context.Context, *UpdateExploreMapCommand) (*ExploreMapDTO, error) + Get(context.Context, *GetExploreMapByUIDQuery) (*ExploreMapDTO, error) + List(context.Context, *GetExploreMapsQuery) (ExploreMaps, error) + Delete(ctx context.Context, cmd *DeleteExploreMapCommand) error +} diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 4439966dc22..034e2936ca2 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -136,12 +136,12 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere if s.cfg.ExploreEnabled && hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) { treeRoot.AddSection(&navtree.NavLink{ - Text: "Explore Map", + Text: "Explore Maps", Id: navtree.NavIDExploreMap, - SubTitle: "Explore your data on an open canvas", - Icon: "apps", + SubTitle: "Explore your data on collaborative canvases", + Icon: "globe", SortWeight: navtree.WeightExplore + 1, - Url: s.cfg.AppSubURL + "/explore-map", + Url: s.cfg.AppSubURL + "/explore-maps", }) } diff --git a/pkg/services/sqlstore/migrations/explore_map_mig.go b/pkg/services/sqlstore/migrations/explore_map_mig.go new file mode 100644 index 00000000000..8ece19f5db8 --- /dev/null +++ b/pkg/services/sqlstore/migrations/explore_map_mig.go @@ -0,0 +1,30 @@ +package migrations + +import ( + . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" +) + +func addExploreMapMigrations(mg *Migrator) { + exploreMapV1 := Table{ + Name: "explore_map", + Columns: []*Column{ + {Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: false}, + {Name: "org_id", Type: DB_BigInt, Nullable: false}, + {Name: "title", Type: DB_NVarchar, Length: 255, Nullable: false}, + {Name: "data", Type: DB_Text, Nullable: false}, + {Name: "created_by", Type: DB_BigInt, Nullable: false}, + {Name: "updated_by", Type: DB_BigInt, Nullable: false}, + {Name: "created_at", Type: DB_DateTime, Nullable: false}, + {Name: "updated_at", Type: DB_DateTime, Nullable: false}, + }, + Indices: []*Index{ + {Cols: []string{"uid", "org_id"}, Type: UniqueIndex}, + {Cols: []string{"org_id"}}, + }, + } + + mg.AddMigration("create explore_map table", NewAddTableMigration(exploreMapV1)) + mg.AddMigration("add unique index explore_map.uid_org_id", NewAddIndexMigration(exploreMapV1, exploreMapV1.Indices[0])) + mg.AddMigration("add index explore_map.org_id", NewAddIndexMigration(exploreMapV1, exploreMapV1.Indices[1])) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index 4fc4e1df131..cdb5f186651 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -66,6 +66,7 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) { ualert.AddDashboardUIDPanelIDMigration(mg) accesscontrol.AddMigration(mg) addQueryHistoryMigrations(mg) + addExploreMapMigrations(mg) accesscontrol.AddDisabledMigrator(mg) accesscontrol.AddTeamMembershipMigrations(mg) diff --git a/public/app/features/explore-map/ExploreMapListPage.tsx b/public/app/features/explore-map/ExploreMapListPage.tsx new file mode 100644 index 00000000000..531a6ed81e3 --- /dev/null +++ b/public/app/features/explore-map/ExploreMapListPage.tsx @@ -0,0 +1,342 @@ +import { css } from '@emotion/css'; +import { useEffect, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { + Button, + ConfirmModal, + ErrorBoundaryAlert, + Input, + LoadingPlaceholder, + useStyles2, +} from '@grafana/ui'; +import { useGrafana } from 'app/core/context/GrafanaContext'; +import { useNavModel } from 'app/core/hooks/useNavModel'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +import { exploreMapApi, ExploreMapListItem } from './api/exploreMapApi'; +import { initialExploreMapState } from './state/types'; + +export default function ExploreMapListPage(props: GrafanaRouteComponentProps) { + const styles = useStyles2(getStyles); + const { chrome } = useGrafana(); + const navModel = useNavModel('explore-map'); + const [maps, setMaps] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [deleteConfirmUid, setDeleteConfirmUid] = useState(null); + const [creatingNew, setCreatingNew] = useState(false); + + useEffect(() => { + chrome.update({ + sectionNav: navModel, + }); + }, [chrome, navModel]); + + useEffect(() => { + loadMaps(); + }, []); + + const loadMaps = async () => { + try { + setLoading(true); + setError(null); + const result = await exploreMapApi.listExploreMaps(100); + setMaps(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load explore maps'); + console.error('Failed to load explore maps:', err); + } finally { + setLoading(false); + } + }; + + const handleCreateNew = async () => { + try { + setCreatingNew(true); + const newMap = await exploreMapApi.createExploreMap({ + title: 'New Explore Map', + data: initialExploreMapState, + }); + // Navigate to the new map + window.location.href = `/explore-maps/${newMap.uid}`; + } catch (err) { + console.error('Failed to create explore map:', err); + setError(err instanceof Error ? err.message : 'Failed to create explore map'); + setCreatingNew(false); + } + }; + + const handleDelete = async (uid: string) => { + try { + await exploreMapApi.deleteExploreMap(uid); + setDeleteConfirmUid(null); + loadMaps(); + } catch (err) { + console.error('Failed to delete explore map:', err); + setError(err instanceof Error ? err.message : 'Failed to delete explore map'); + } + }; + + const filteredMaps = maps.filter((map) => + map.title.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) { + return 'just now'; + } else if (diffMins < 60) { + return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`; + } else if (diffHours < 24) { + return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + } else if (diffDays < 7) { + return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + } else { + return date.toLocaleDateString(); + } + }; + + const mapToDelete = maps.find((m) => m.uid === deleteConfirmUid); + + return ( + +
+

+ Explore Maps +

+ +
+
+

Explore Maps

+

+ Create and manage collaborative exploration canvases with multiple panels +

+
+ +
+ + {error && ( +
+

{error}

+ +
+ )} + +
+ } + placeholder="Search maps..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.currentTarget.value)} + className={styles.searchInput} + /> +
+ + {loading ? ( + + ) : filteredMaps.length === 0 ? ( +
+
+ {searchQuery ? ( + <> +

No maps found

+

Try adjusting your search query

+ + ) : ( + <> +

No explore maps yet

+

+ Create your first explore map to get started with collaborative exploration +

+ + + )} +
+
+ ) : ( +
+ {filteredMaps.map((map) => ( +
+
+

{map.title}

+
+ + Updated {formatDate(map.updatedAt)} + +
+
+
+ +
+
+ ))} +
+ )} + + {deleteConfirmUid && mapToDelete && ( + + Are you sure you want to delete {mapToDelete.title}? This action cannot be + undone. + + } + confirmText="Delete" + onConfirm={() => handleDelete(deleteConfirmUid)} + onDismiss={() => setDeleteConfirmUid(null)} + /> + )} +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + pageWrapper: css({ + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'auto', + backgroundColor: theme.colors.background.primary, + padding: theme.spacing(3), + }), + header: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: theme.spacing(3), + }), + headerContent: css({ + flex: 1, + }), + pageTitle: css({ + fontSize: theme.typography.h2.fontSize, + fontWeight: theme.typography.h2.fontWeight, + margin: 0, + marginBottom: theme.spacing(1), + }), + pageDescription: css({ + color: theme.colors.text.secondary, + margin: 0, + }), + controls: css({ + marginBottom: theme.spacing(3), + }), + searchInput: css({ + maxWidth: '400px', + }), + errorMessage: css({ + padding: theme.spacing(2), + backgroundColor: theme.colors.error.main, + color: theme.colors.error.contrastText, + borderRadius: theme.shape.radius.default, + marginBottom: theme.spacing(3), + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + '& p': { + margin: 0, + }, + }), + emptyState: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '400px', + }), + emptyStateContent: css({ + textAlign: 'center', + maxWidth: '600px', + }), + emptyStateTitle: css({ + fontSize: theme.typography.h3.fontSize, + fontWeight: theme.typography.h3.fontWeight, + marginBottom: theme.spacing(2), + }), + emptyStateText: css({ + color: theme.colors.text.secondary, + marginBottom: theme.spacing(3), + }), + mapGrid: css({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', + gap: theme.spacing(2), + }), + mapCard: css({ + border: `1px solid ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + padding: theme.spacing(2), + backgroundColor: theme.colors.background.secondary, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + transition: 'all 0.2s', + '&:hover': { + borderColor: theme.colors.border.strong, + boxShadow: theme.shadows.z2, + }, + }), + mapCardContent: css({ + flex: 1, + }), + mapTitle: css({ + fontSize: theme.typography.h4.fontSize, + fontWeight: theme.typography.h4.fontWeight, + margin: 0, + marginBottom: theme.spacing(1), + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }), + mapMeta: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + }), + metaItem: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }), + mapCardActions: css({ + display: 'flex', + gap: theme.spacing(1), + justifyContent: 'flex-end', + }), + }; +}; diff --git a/public/app/features/explore-map/ExploreMapPage.tsx b/public/app/features/explore-map/ExploreMapPage.tsx index 6339a2a423c..e345e173d0c 100644 --- a/public/app/features/explore-map/ExploreMapPage.tsx +++ b/public/app/features/explore-map/ExploreMapPage.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import { useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom-v5-compat'; import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; import { GrafanaTheme2 } from '@grafana/data'; @@ -15,14 +16,15 @@ import { ExploreMapToolbar } from './components/ExploreMapToolbar'; import { TransformProvider } from './context/TransformContext'; import { useCanvasPersistence } from './hooks/useCanvasPersistence'; -export default function ExploreMapPage(props: GrafanaRouteComponentProps) { +export default function ExploreMapPage(props: GrafanaRouteComponentProps<{ uid?: string }>) { const styles = useStyles2(getStyles); const { chrome } = useGrafana(); const navModel = useNavModel('explore-map'); const transformRef = useRef(null); + const { uid } = useParams<{ uid?: string }>(); - // Initialize canvas persistence - useCanvasPersistence(); + // Initialize canvas persistence (with uid if available) + const { loading } = useCanvasPersistence({ uid }); useEffect(() => { chrome.update({ @@ -30,6 +32,14 @@ export default function ExploreMapPage(props: GrafanaRouteComponentProps) { }); }, [chrome, navModel]); + if (loading) { + return ( +
+

Loading explore map...

+
+ ); + } + return ( @@ -37,7 +47,7 @@ export default function ExploreMapPage(props: GrafanaRouteComponentProps) {

Explore Map

- + @@ -56,5 +66,13 @@ const getStyles = (theme: GrafanaTheme2) => { overflow: 'hidden', backgroundColor: theme.colors.background.primary, }), + loadingWrapper: css({ + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.background.primary, + }), }; }; diff --git a/public/app/features/explore-map/api/exploreMapApi.ts b/public/app/features/explore-map/api/exploreMapApi.ts new file mode 100644 index 00000000000..c403a2aac1c --- /dev/null +++ b/public/app/features/explore-map/api/exploreMapApi.ts @@ -0,0 +1,77 @@ +import { getBackendSrv } from '@grafana/runtime'; + +import { ExploreMapState } from '../state/types'; + +export interface ExploreMapDTO { + uid: string; + title: string; + data: string; // JSON-encoded ExploreMapState + createdBy: number; + updatedBy: number; + createdAt: string; + updatedAt: string; +} + +export interface ExploreMapListItem { + uid: string; + title: string; + createdBy: number; + updatedBy: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateExploreMapRequest { + title: string; + data: ExploreMapState; +} + +export interface UpdateExploreMapRequest { + title: string; + data: ExploreMapState; +} + +export interface ExploreMapCreateResponse { + id: number; + uid: string; + title: string; + data: string; + orgID: number; + createdBy: number; + updatedBy: number; + createdAt: string; + updatedAt: string; +} + +export class ExploreMapApi { + private baseUrl = '/api/explore-maps'; + + async listExploreMaps(limit?: number): Promise { + const params = limit ? { limit } : {}; + return getBackendSrv().get(this.baseUrl, params); + } + + async getExploreMap(uid: string): Promise { + return getBackendSrv().get(`${this.baseUrl}/${uid}`); + } + + async createExploreMap(request: CreateExploreMapRequest): Promise { + return getBackendSrv().post(this.baseUrl, { + title: request.title, + data: JSON.stringify(request.data), + }); + } + + async updateExploreMap(uid: string, request: UpdateExploreMapRequest): Promise { + return getBackendSrv().put(`${this.baseUrl}/${uid}`, { + title: request.title, + data: JSON.stringify(request.data), + }); + } + + async deleteExploreMap(uid: string): Promise { + return getBackendSrv().delete(`${this.baseUrl}/${uid}`); + } +} + +export const exploreMapApi = new ExploreMapApi(); diff --git a/public/app/features/explore-map/components/ExploreMapToolbar.tsx b/public/app/features/explore-map/components/ExploreMapToolbar.tsx index 68eb3f4e07b..7ec1f65bb40 100644 --- a/public/app/features/explore-map/components/ExploreMapToolbar.tsx +++ b/public/app/features/explore-map/components/ExploreMapToolbar.tsx @@ -1,24 +1,45 @@ import { css } from '@emotion/css'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; -import { ButtonGroup, ConfirmModal, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { Button, ButtonGroup, ConfirmModal, Input, ToolbarButton, useStyles2 } from '@grafana/ui'; import { useDispatch, useSelector } from 'app/types/store'; import { useTransformContext } from '../context/TransformContext'; import { useCanvasPersistence } from '../hooks/useCanvasPersistence'; -import { resetCanvas } from '../state/exploreMapSlice'; +import { resetCanvas, updateMapTitle } from '../state/exploreMapSlice'; -export function ExploreMapToolbar() { +interface ExploreMapToolbarProps { + uid?: string; +} + +export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) { const styles = useStyles2(getStyles); const dispatch = useDispatch(); - const { exportCanvas, importCanvas } = useCanvasPersistence(); + const { exportCanvas, importCanvas, saving, lastSaved } = useCanvasPersistence({ uid }); const { transformRef } = useTransformContext(); const [showResetConfirm, setShowResetConfirm] = useState(false); + const [editingTitle, setEditingTitle] = useState(false); + const [titleValue, setTitleValue] = useState(''); + const titleInputRef = useRef(null); const panelCount = useSelector((state) => Object.keys(state.exploreMap.panels).length); const viewport = useSelector((state) => state.exploreMap.viewport); + const mapTitle = useSelector((state) => state.exploreMap.title); + + useEffect(() => { + if (mapTitle) { + setTitleValue(mapTitle); + } + }, [mapTitle]); + + useEffect(() => { + if (editingTitle && titleInputRef.current) { + titleInputRef.current.focus(); + titleInputRef.current.select(); + } + }, [editingTitle]); const handleResetCanvas = useCallback(() => { setShowResetConfirm(true); @@ -62,15 +83,88 @@ export function ExploreMapToolbar() { importCanvas(); }, [importCanvas]); + const handleTitleClick = useCallback(() => { + if (uid) { + // Only allow editing in API mode + setEditingTitle(true); + } + }, [uid]); + + const handleTitleBlur = useCallback(() => { + setEditingTitle(false); + if (titleValue.trim() && titleValue !== mapTitle) { + dispatch(updateMapTitle({ title: titleValue.trim() })); + } else { + setTitleValue(mapTitle || ''); + } + }, [dispatch, mapTitle, titleValue]); + + const handleTitleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleTitleBlur(); + } else if (e.key === 'Escape') { + setTitleValue(mapTitle || ''); + setEditingTitle(false); + } + }, + [handleTitleBlur, mapTitle] + ); + + const getSaveStatus = () => { + if (!uid) { + return null; // No status in localStorage mode + } + if (saving) { + return Saving...; + } + if (lastSaved) { + const secondsAgo = Math.floor((Date.now() - lastSaved.getTime()) / 1000); + if (secondsAgo < 5) { + return Saved; + } + } + return null; + }; + return ( <>
- - - {{ count: panelCount }} panels - - + {uid && ( +
@@ -146,5 +240,38 @@ const getStyles = (theme: GrafanaTheme2) => { color: theme.colors.text.secondary, marginLeft: theme.spacing(1), }), + titleDisplay: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(0.5, 1), + cursor: 'pointer', + borderRadius: theme.shape.radius.default, + transition: 'background-color 0.2s', + '&:hover': { + backgroundColor: theme.colors.background.primary, + '& .fa-pencil': { + opacity: 1, + }, + }, + '& .fa-pencil': { + opacity: 0.5, + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + }, + }), + title: css({ + margin: 0, + fontSize: theme.typography.h4.fontSize, + fontWeight: theme.typography.h4.fontWeight, + }), + titleInput: css({ + width: '300px', + }), + saveStatus: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + fontStyle: 'italic', + }), }; }; diff --git a/public/app/features/explore-map/hooks/useCanvasPersistence.ts b/public/app/features/explore-map/hooks/useCanvasPersistence.ts index 42e855527c2..79bbfad1118 100644 --- a/public/app/features/explore-map/hooks/useCanvasPersistence.ts +++ b/public/app/features/explore-map/hooks/useCanvasPersistence.ts @@ -1,34 +1,119 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { store } from '@grafana/data'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; import { useDispatch, useSelector } from 'app/types/store'; +import { exploreMapApi } from '../api/exploreMapApi'; import { loadCanvas } from '../state/exploreMapSlice'; import { ExploreMapState, initialExploreMapState, SerializedExploreState } from '../state/types'; const STORAGE_KEY = 'grafana.exploreMap.state'; +const AUTO_SAVE_DELAY_MS = 2000; -export function useCanvasPersistence() { +interface UseMapPersistenceOptions { + uid?: string; // If provided, load from and save to API. If not, use localStorage (legacy mode) +} + +export function useCanvasPersistence(options: UseMapPersistenceOptions = {}) { + const { uid } = options; const dispatch = useDispatch(); const exploreMapState = useSelector((state) => state.exploreMap); const exploreState = useSelector((state) => state.explore); + const [loading, setLoading] = useState(!!uid); + const [saving, setSaving] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const saveTimeoutRef = useRef(null); + const initialLoadDone = useRef(false); - // Load state from storage on mount + // Helper to enrich state with Explore pane data + const enrichStateWithExploreData = useCallback( + (state: ExploreMapState): ExploreMapState => { + return { + ...state, + selectedPanelIds: [], // Don't persist selection state + cursors: {}, // Don't persist cursor state - it's ephemeral + panels: Object.fromEntries( + Object.entries(state.panels).map(([panelId, panel]) => { + const explorePane = exploreState.panes?.[panel.exploreId]; + + let exploreStateToSave: SerializedExploreState | undefined = undefined; + if (explorePane) { + exploreStateToSave = { + queries: explorePane.queries, + datasourceUid: explorePane.datasourceInstance?.uid, + range: explorePane.range, + refreshInterval: explorePane.refreshInterval, + panelsState: explorePane.panelsState, + compact: explorePane.compact, + }; + } + + return [ + panelId, + { + ...panel, + exploreState: exploreStateToSave, + }, + ]; + }) + ), + }; + }, + [exploreState] + ); + + // Load state on mount useEffect(() => { - try { - const savedState = store.get(STORAGE_KEY); - if (savedState) { - const parsed: ExploreMapState = JSON.parse(savedState); - dispatch(loadCanvas(parsed)); - } - } catch (error) { - console.error('Failed to load canvas state from storage:', error); + if (initialLoadDone.current) { + return; } - }, [dispatch]); + initialLoadDone.current = true; - // Save state to storage whenever it changes, including Explore state + const loadState = async () => { + if (uid) { + // Load from API + try { + setLoading(true); + const mapData = await exploreMapApi.getExploreMap(uid); + const parsed: ExploreMapState = JSON.parse(mapData.data); + // Use title from DB column, not from JSON data + parsed.uid = mapData.uid; + parsed.title = mapData.title; + dispatch(loadCanvas(parsed)); + } catch (error) { + console.error('Failed to load map from API:', error); + dispatch( + notifyApp( + createErrorNotification('Failed to load explore map', 'The map may not exist or you may not have access') + ) + ); + // Redirect to list page after a delay + setTimeout(() => { + window.location.href = '/explore-maps'; + }, 2000); + } finally { + setLoading(false); + } + } else { + // Load from localStorage (legacy mode) + try { + const savedState = store.get(STORAGE_KEY); + if (savedState) { + const parsed: ExploreMapState = JSON.parse(savedState); + dispatch(loadCanvas(parsed)); + } + } catch (error) { + console.error('Failed to load canvas state from storage:', error); + } + } + }; + + loadState(); + }, [dispatch, uid]); + + // Auto-save to API or localStorage useEffect(() => { // Don't persist an empty canvas; this avoids removing a previously saved // non-empty canvas when the in-memory state is still at its initial value. @@ -36,79 +121,70 @@ export function useCanvasPersistence() { return; } - try { - // Enrich exploreMapState with current Explore state for each panel - const enrichedState: ExploreMapState = { - ...exploreMapState, - selectedPanelIds: [], // Don't persist selection state - cursors: {}, // Don't persist cursor state - it's ephemeral - panels: Object.fromEntries( - Object.entries(exploreMapState.panels).map(([panelId, panel]) => { - const explorePane = exploreState.panes?.[panel.exploreId]; - - let exploreStateToSave: SerializedExploreState | undefined = undefined; - if (explorePane) { - exploreStateToSave = { - queries: explorePane.queries, - datasourceUid: explorePane.datasourceInstance?.uid, - range: explorePane.range, - refreshInterval: explorePane.refreshInterval, - panelsState: explorePane.panelsState, - compact: explorePane.compact, - }; - } - - return [ - panelId, - { - ...panel, - exploreState: exploreStateToSave, - }, - ]; - }) - ), - }; - - store.set(STORAGE_KEY, JSON.stringify(enrichedState)); - } catch (error) { - console.error('Failed to save canvas state to storage:', error); + // Skip auto-save during initial load + if (!initialLoadDone.current || loading) { + return; } - }, [exploreMapState, exploreState]); - const exportCanvas = () => { + // Clear any pending save + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + const saveState = async () => { + const enrichedState = enrichStateWithExploreData(exploreMapState); + + if (uid) { + // Save to API with debounce + saveTimeoutRef.current = setTimeout(async () => { + try { + setSaving(true); + const titleToSave = exploreMapState.title || 'Untitled Map'; + // Ensure data also has the correct title + const dataToSave = { + ...enrichedState, + uid: exploreMapState.uid, + title: titleToSave, + }; + await exploreMapApi.updateExploreMap(uid, { + title: titleToSave, + data: dataToSave, + }); + setLastSaved(new Date()); + } catch (error) { + console.error('Failed to save map to API:', error); + dispatch(notifyApp(createErrorNotification('Failed to save explore map', 'Changes may not be persisted'))); + } finally { + setSaving(false); + } + }, AUTO_SAVE_DELAY_MS); + } else { + // Save to localStorage immediately (legacy mode) + try { + store.set(STORAGE_KEY, JSON.stringify(enrichedState)); + } catch (error) { + console.error('Failed to save canvas state to storage:', error); + } + } + }; + + saveState(); + + // Cleanup timeout on unmount + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, [exploreMapState, exploreState, enrichStateWithExploreData, dispatch, loading, uid]); + + const exportCanvas = useCallback(() => { try { // Enrich with Explore state before exporting - const enrichedState: ExploreMapState = { + const enrichedState = enrichStateWithExploreData({ ...exploreMapState, viewport: initialExploreMapState.viewport, // Don't export viewport state - use initial centered viewport - selectedPanelIds: [], // Don't export selection state - cursors: {}, // Don't export cursor state - it's ephemeral - panels: Object.fromEntries( - Object.entries(exploreMapState.panels).map(([panelId, panel]) => { - const explorePane = exploreState.panes?.[panel.exploreId]; - - let exploreStateToSave: SerializedExploreState | undefined = undefined; - if (explorePane) { - exploreStateToSave = { - queries: explorePane.queries, - datasourceUid: explorePane.datasourceInstance?.uid, - range: explorePane.range, - refreshInterval: explorePane.refreshInterval, - panelsState: explorePane.panelsState, - compact: explorePane.compact, - }; - } - - return [ - panelId, - { - ...panel, - exploreState: exploreStateToSave, - }, - ]; - }) - ), - }; + }); const dataStr = JSON.stringify(enrichedState, null, 2); const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr); @@ -122,13 +198,11 @@ export function useCanvasPersistence() { dispatch(notifyApp(createSuccessNotification('Canvas exported successfully'))); } catch (error) { console.error('Failed to export canvas:', error); - dispatch( - notifyApp(createErrorNotification('Failed to export canvas', 'Check console for details')) - ); + dispatch(notifyApp(createErrorNotification('Failed to export canvas', 'Check console for details'))); } - }; + }, [dispatch, enrichStateWithExploreData, exploreMapState]); - const importCanvas = () => { + const importCanvas = useCallback(() => { try { const input = document.createElement('input'); input.type = 'file'; @@ -155,9 +229,7 @@ export function useCanvasPersistence() { dispatch(notifyApp(createSuccessNotification('Canvas imported successfully'))); } catch (error) { console.error('Failed to parse imported canvas:', error); - dispatch( - notifyApp(createErrorNotification('Failed to import canvas', 'Invalid file format')) - ); + dispatch(notifyApp(createErrorNotification('Failed to import canvas', 'Invalid file format'))); } }; reader.readAsText(file); @@ -166,13 +238,14 @@ export function useCanvasPersistence() { input.click(); } catch (error) { console.error('Failed to import canvas:', error); - dispatch( - notifyApp(createErrorNotification('Failed to import canvas', 'Check console for details')) - ); + dispatch(notifyApp(createErrorNotification('Failed to import canvas', 'Check console for details'))); } - }; + }, [dispatch]); return { + loading, + saving, + lastSaved, exportCanvas, importCanvas, }; diff --git a/public/app/features/explore-map/state/exploreMapSlice.ts b/public/app/features/explore-map/state/exploreMapSlice.ts index b1a97313170..c80a5ca51c7 100644 --- a/public/app/features/explore-map/state/exploreMapSlice.ts +++ b/public/app/features/explore-map/state/exploreMapSlice.ts @@ -220,6 +220,19 @@ const exploreMapSlice = createSlice({ removeCursor: (state, action: PayloadAction<{ userId: string }>) => { delete state.cursors[action.payload.userId]; }, + + setMapMetadata: (state, action: PayloadAction<{ uid?: string; title?: string }>) => { + state.uid = action.payload.uid; + state.title = action.payload.title; + }, + + updateMapTitle: (state, action: PayloadAction<{ title: string }>) => { + state.title = action.payload.title; + }, + + clearMap: () => { + return initialExploreMapState; + }, }, }); @@ -238,6 +251,9 @@ export const { loadCanvas, updateCursor, removeCursor, + setMapMetadata, + updateMapTitle, + clearMap, } = exploreMapSlice.actions; export const exploreMapReducer = exploreMapSlice.reducer; diff --git a/public/app/features/explore-map/state/types.ts b/public/app/features/explore-map/state/types.ts index 0110d766d0d..dc29c31c079 100644 --- a/public/app/features/explore-map/state/types.ts +++ b/public/app/features/explore-map/state/types.ts @@ -40,6 +40,8 @@ export interface UserCursor { } export interface ExploreMapState { + uid?: string; + title?: string; viewport: CanvasViewport; panels: Record; selectedPanelIds: string[]; @@ -48,6 +50,8 @@ export interface ExploreMapState { } export const initialExploreMapState: ExploreMapState = { + uid: undefined, + title: undefined, viewport: { zoom: 1, // Center the viewport at canvas center (5000, 5000) diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 68bafce175a..ec763bd4988 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -171,7 +171,17 @@ export function getAppRoutes(): RouteDescriptor[] { ), }, { - path: '/explore-map', + path: '/explore-maps', + pageClass: 'page-explore-maps', + roles: () => contextSrv.evaluatePermission([AccessControlAction.DataSourcesExplore]), + component: SafeDynamicImport(() => + config.exploreEnabled + ? import(/* webpackChunkName: "explore-map-list" */ 'app/features/explore-map/ExploreMapListPage') + : import(/* webpackChunkName: "explore-feature-toggle-page" */ 'app/features/explore/FeatureTogglePage') + ), + }, + { + path: '/explore-maps/:uid', pageClass: 'page-explore-map', roles: () => contextSrv.evaluatePermission([AccessControlAction.DataSourcesExplore]), component: SafeDynamicImport(() =>