Persist maps to SQL store

This commit is contained in:
Christian Simon
2025-12-01 14:01:46 +00:00
parent ac188c1fe1
commit b9ae0c98b6
21 changed files with 1378 additions and 115 deletions
+3
View File
@@ -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))
+227
View File
@@ -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"})
}
+4 -1
View File
@@ -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,
+2
View File
@@ -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,
+6 -3
View File
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
}
+76
View File
@@ -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
}
+13
View File
@@ -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
}
+4 -4
View File
@@ -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)
+11 -1
View File
@@ -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(() =>