Persist maps to SQL store
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+6
-3
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ExploreMapListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [deleteConfirmUid, setDeleteConfirmUid] = useState<string | null>(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 (
|
||||
<ErrorBoundaryAlert>
|
||||
<div className={styles.pageWrapper}>
|
||||
<h1 className="sr-only">
|
||||
<Trans i18nKey="nav.explore-maps.title">Explore Maps</Trans>
|
||||
</h1>
|
||||
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<h2 className={styles.pageTitle}>Explore Maps</h2>
|
||||
<p className={styles.pageDescription}>
|
||||
Create and manage collaborative exploration canvases with multiple panels
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="plus" onClick={handleCreateNew} disabled={creatingNew}>
|
||||
{creatingNew ? 'Creating...' : 'Create new map'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.errorMessage}>
|
||||
<p>{error}</p>
|
||||
<Button variant="secondary" size="sm" onClick={loadMaps}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.controls}>
|
||||
<Input
|
||||
prefix={<span className="fa fa-search" />}
|
||||
placeholder="Search maps..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingPlaceholder text="Loading explore maps..." />
|
||||
) : filteredMaps.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyStateContent}>
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<p className={styles.emptyStateTitle}>No maps found</p>
|
||||
<p className={styles.emptyStateText}>Try adjusting your search query</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.emptyStateTitle}>No explore maps yet</p>
|
||||
<p className={styles.emptyStateText}>
|
||||
Create your first explore map to get started with collaborative exploration
|
||||
</p>
|
||||
<Button icon="plus" onClick={handleCreateNew} disabled={creatingNew}>
|
||||
Create your first map
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.mapGrid}>
|
||||
{filteredMaps.map((map) => (
|
||||
<div key={map.uid} className={styles.mapCard}>
|
||||
<div className={styles.mapCardContent}>
|
||||
<h3 className={styles.mapTitle}>{map.title}</h3>
|
||||
<div className={styles.mapMeta}>
|
||||
<span className={styles.metaItem}>
|
||||
<span className="fa fa-clock-o" /> Updated {formatDate(map.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mapCardActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => (window.location.href = `/explore-maps/${map.uid}`)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
fill="text"
|
||||
size="sm"
|
||||
icon="trash-alt"
|
||||
onClick={() => setDeleteConfirmUid(map.uid)}
|
||||
aria-label="Delete map"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteConfirmUid && mapToDelete && (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
title="Delete explore map"
|
||||
body={
|
||||
<>
|
||||
Are you sure you want to delete <strong>{mapToDelete.title}</strong>? This action cannot be
|
||||
undone.
|
||||
</>
|
||||
}
|
||||
confirmText="Delete"
|
||||
onConfirm={() => handleDelete(deleteConfirmUid)}
|
||||
onDismiss={() => setDeleteConfirmUid(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundaryAlert>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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<ReactZoomPanPinchRef>(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 (
|
||||
<div className={styles.loadingWrapper}>
|
||||
<p>Loading explore map...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundaryAlert>
|
||||
<TransformProvider value={{ transformRef }}>
|
||||
@@ -37,7 +47,7 @@ export default function ExploreMapPage(props: GrafanaRouteComponentProps) {
|
||||
<h1 className="sr-only">
|
||||
<Trans i18nKey="nav.explore-map.title">Explore Map</Trans>
|
||||
</h1>
|
||||
<ExploreMapToolbar />
|
||||
<ExploreMapToolbar uid={uid} />
|
||||
<ExploreMapCanvas />
|
||||
<ExploreMapFloatingToolbar />
|
||||
</div>
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<ExploreMapListItem[]> {
|
||||
const params = limit ? { limit } : {};
|
||||
return getBackendSrv().get(this.baseUrl, params);
|
||||
}
|
||||
|
||||
async getExploreMap(uid: string): Promise<ExploreMapDTO> {
|
||||
return getBackendSrv().get(`${this.baseUrl}/${uid}`);
|
||||
}
|
||||
|
||||
async createExploreMap(request: CreateExploreMapRequest): Promise<ExploreMapCreateResponse> {
|
||||
return getBackendSrv().post(this.baseUrl, {
|
||||
title: request.title,
|
||||
data: JSON.stringify(request.data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateExploreMap(uid: string, request: UpdateExploreMapRequest): Promise<ExploreMapDTO> {
|
||||
return getBackendSrv().put(`${this.baseUrl}/${uid}`, {
|
||||
title: request.title,
|
||||
data: JSON.stringify(request.data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteExploreMap(uid: string): Promise<void> {
|
||||
return getBackendSrv().delete(`${this.baseUrl}/${uid}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const exploreMapApi = new ExploreMapApi();
|
||||
@@ -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<HTMLInputElement>(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 <span className={styles.saveStatus}>Saving...</span>;
|
||||
}
|
||||
if (lastSaved) {
|
||||
const secondsAgo = Math.floor((Date.now() - lastSaved.getTime()) / 1000);
|
||||
if (secondsAgo < 5) {
|
||||
return <span className={styles.saveStatus}>Saved</span>;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarSection}>
|
||||
<span className={styles.panelCount}>
|
||||
<Trans i18nKey="explore-map.toolbar.panel-count" values={{ count: panelCount }}>
|
||||
{{ count: panelCount }} panels
|
||||
</Trans>
|
||||
</span>
|
||||
{uid && (
|
||||
<Button
|
||||
icon="arrow-left"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => (window.location.href = '/explore-maps')}
|
||||
tooltip="Back to maps list"
|
||||
fill="text"
|
||||
/>
|
||||
)}
|
||||
{uid && mapTitle !== undefined ? (
|
||||
editingTitle ? (
|
||||
<Input
|
||||
ref={titleInputRef}
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.currentTarget.value)}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
className={styles.titleInput}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.titleDisplay} onClick={handleTitleClick}>
|
||||
<h2 className={styles.title}>{mapTitle || 'Untitled Map'}</h2>
|
||||
<span className="fa fa-pencil" />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<span className={styles.panelCount}>
|
||||
<Trans i18nKey="explore-map.toolbar.panel-count" values={{ count: panelCount }}>
|
||||
{{ count: panelCount }} panels
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
{getSaveStatus()}
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbarSection}>
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<Date | null>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface UserCursor {
|
||||
}
|
||||
|
||||
export interface ExploreMapState {
|
||||
uid?: string;
|
||||
title?: string;
|
||||
viewport: CanvasViewport;
|
||||
panels: Record<string, ExploreMapPanel>;
|
||||
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)
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
Reference in New Issue
Block a user