Compare commits

...

61 Commits

Author SHA1 Message Date
Aleksandar Petrov
270415c5bd Add global time range selector 2025-12-04 17:13:14 -04:00
Aleksandar Petrov
b8e86bb46f Associate sticky notes with frames 2025-12-04 16:00:23 -04:00
Aleksandar Petrov
12c04d55f6 Add an option to copy/paste panels between maps 2025-12-04 15:14:41 -04:00
Christian Simon
efc5e97a74 Use hacked ephemeral deployer 2025-12-04 17:03:25 +00:00
Christian Simon
022f8847da Run on every push of this branch 2025-12-04 16:30:53 +00:00
Christian Simon
1628e1e0da Use the correct vault path 2025-12-04 16:22:54 +00:00
Christian Simon
93b91e4374 CI: Add Docker Hub authentication to ephemeral instances workflow
Add Docker Hub login step to avoid unauthenticated image pull
rate-limiting in the ephemeral-instances-pr-comment workflow.
2025-12-04 16:22:54 +00:00
Aleksandar Petrov
bcde82d955 Fix issue with persisting profiles drilldown panels 2025-12-04 11:54:06 -04:00
Christian Simon
07218f3417 Remove console logs 2025-12-04 14:03:34 +00:00
Christian Simon
32175192fb Update "explore maps" to "atlas" 2025-12-04 13:55:20 +00:00
Christian Simon
ccaf288016 Fix linting complaints 2025-12-04 13:55:20 +00:00
Aleksandar Petrov
dacd82c42c Fix a few TS errors 2025-12-04 09:20:32 -04:00
Aleksandar Petrov
39ff7d4b20 Add cursor modes 2025-12-04 09:15:58 -04:00
Christian Simon
fff727a632 Limit frame header width 2025-12-04 13:11:02 +00:00
Christian Simon
fe8a7c052a Add colour and emoji to frames 2025-12-04 13:11:02 +00:00
Aleksandar Petrov
4e2849f1a9 Add minimap, increase canvas size 2025-12-04 09:01:01 -04:00
Joey
329da8298a Make blue default sticky note color 2025-12-04 12:12:04 +00:00
Joey
91ba4bc419 Fix sticky note conflicts with frames 2025-12-04 12:09:43 +00:00
Aleksandar Petrov
66ed7e526a Add frames 2025-12-03 18:45:56 -04:00
Aleksandar Petrov
0b5a7b91fc Fix the time range for queries created by the assistant 2025-12-03 12:13:20 -04:00
Christian Simon
cd0d3a8d0a Ensure the local session is filtered 2025-12-03 12:09:23 +00:00
Christian Simon
34e031e40a Add edge-of-view cursor indicators for collaborative editing 2025-12-03 11:53:34 +00:00
Joey
c048f25d67 Show currently active users in the worlds card list 2025-12-03 10:55:36 +00:00
Joey
23c6c48e9e Show currently active users in world in top bar 2025-12-03 10:54:20 +00:00
Joey
00ce9b57b5 Remove breakcrumbs from Drilldown Apps 2025-12-03 10:54:19 +00:00
Joey
1d1d8bcbdb Fix Drilldown sync so all sessions see same url 2025-12-03 10:54:18 +00:00
Aleksandar Petrov
29c5cb7b1c Add component to be rendered by the assistant 2025-12-02 17:17:42 -04:00
Aleksandar Petrov
75bd64ff6b Add dummy assistant integration 2025-12-02 16:16:05 -04:00
Aleksandar Petrov
bf9e6eefd1 Change icon for adding explore panels 2025-12-02 15:58:06 -04:00
Christian Simon
6692d08f7e Fix some linting problems 2025-12-02 17:15:26 +00:00
Christian Simon
d3869df7ce Do not 404 on /explore-maps 2025-12-02 17:15:13 +00:00
Aleksandar Petrov
4579df3867 Broadcast panel selection to other users, rework panel actions 2025-12-02 11:50:32 -04:00
Aleksandar Petrov
9b560f1413 Fix cursor rendering when boards are at different zoom levels 2025-12-02 10:53:01 -04:00
Aleksandar Petrov
5c49ba8e6a Make cursors readable at any zoom level 2025-12-02 10:40:10 -04:00
Aleksandar Petrov
f500500927 Show the creator of a panel 2025-12-02 10:21:50 -04:00
Aleksandar Petrov
ba65f18678 Fix excessive re-renders, dragging multiple panels 2025-12-02 10:02:10 -04:00
Joey
0fc79c4655 Add proper - color - logos for Drilldown 2025-12-02 13:27:44 +00:00
Joey
11d1cb54c2 Comments 2025-12-02 13:27:41 +00:00
Joey
ecfe2bd4b2 Simplify Drilldowns 2025-12-02 13:27:38 +00:00
Christian Simon
f6e547a7e4 Implement cursor sync via websocket broadcast 2025-12-02 10:47:10 +00:00
Joey
b0c7930d6d Add support for Logs and Profiles Drilldown 2025-12-02 10:30:42 +00:00
Joey
b1b0af06e7 Add Metrics Drilldown 2025-12-02 09:40:36 +00:00
Joey
3111cc9283 Change default panel size for Drilldown 2025-12-02 09:31:13 +00:00
Joey
b83b173c35 Add Traces Drilldown panel 2025-12-02 09:25:39 +00:00
Aleksandar Petrov
19a6441467 Sync panel internal state across sessions 2025-12-01 18:42:55 -04:00
Aleksandar Petrov
71526b2ab9 Add state syncing via CRDT 2025-12-01 17:08:56 -04:00
Christian Simon
b9ae0c98b6 Persist maps to SQL store 2025-12-01 14:01:46 +00:00
Joey
ac188c1fe1 Fix for persistence on page reload 2025-12-01 11:04:42 +00:00
Aleksandar Petrov
32764bf74e Replace grid lines with dots 2025-11-28 19:52:16 -04:00
Aleksandar Petrov
61e8917605 Add floating toolbar 2025-11-28 19:48:30 -04:00
Aleksandar Petrov
1becc14b2d Select and move multiple panels together 2025-11-28 19:32:39 -04:00
Aleksandar Petrov
751e87a9e8 Improve grid 2025-11-28 18:09:34 -04:00
Aleksandar Petrov
3af9cb8543 Make canvas bigger, start in the middle 2025-11-28 18:04:33 -04:00
Aleksandar Petrov
09d0deb180 Fix lint errors 2025-11-28 17:37:09 -04:00
Aleksandar Petrov
ff28bcb0b2 Fix dragging when zoomed out / in 2025-11-28 17:37:03 -04:00
Aleksandar Petrov
dd23b87e4f Fix zoom reset, lint errors 2025-11-28 17:19:48 -04:00
Aleksandar Petrov
9e6312bf61 A hack to fix broken layout when resizing panels 2025-11-28 17:13:44 -04:00
Aleksandar Petrov
0b31d6b0cf Replace alerts with notifications 2025-11-28 13:30:48 -04:00
Aleksandar Petrov
81738addac Persist explore panel state 2025-11-28 13:21:09 -04:00
Aleksandar Petrov
1e687c9d6c Add mock cursors 2025-11-27 17:38:30 -04:00
Christian Simon
adbbe76dc7 WIP: GraCoCa - Vibe coding session #1 2025-11-27 17:07:01 +00:00
78 changed files with 16539 additions and 11 deletions

View File

@@ -1,16 +1,15 @@
name: "Ephemeral instances"
on:
issue_comment:
types: [created]
pull_request:
types: [closed]
push:
branches:
- 20251127_hackathon-2025-12-gracoca
permissions: {}
jobs:
handle-ephemeral-instances:
if: ${{ github.repository_owner == 'grafana' && ((github.event.issue.pull_request && startsWith(github.event.comment.body, '/deploy-to-hg')) || github.event.action == 'closed') }}
if: ${{ github.repository_owner == 'grafana' }}
runs-on:
labels: ubuntu-x64-xlarge
continue-on-error: true
@@ -33,6 +32,16 @@ jobs:
GCOM_TOKEN=ephemeral-instances-bot:gcom-token
REGISTRY=ephemeral-instances-bot:registry
GCP_SA_ACCOUNT_KEY_BASE64=ephemeral-instances-bot:sa-key
# Secrets placed in the ci/common/<path> path in Vault
common_secrets: |
DOCKERHUB_USERNAME=dockerhub:username
DOCKERHUB_PASSWORD=dockerhub:password
- name: Log in to Docker Hub to avoid unauthenticated image pull rate-limiting
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_PASSWORD }}
- name: Generate a GitHub app installation token
id: generate_token
@@ -44,9 +53,9 @@ jobs:
- name: Checkout ephemeral instances repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
with:
repository: grafana/ephemeral-grafana-instances-github-action
repository: grafana/hackathon-2025-12-gracoca-ephemeral-grafana-instances-github-action
token: ${{ steps.generate_token.outputs.token }}
ref: main
ref: 20251127_hackathon-2025-12-gracoca
path: ephemeral
persist-credentials: false

View File

@@ -404,6 +404,7 @@
"react-redux": "9.2.0",
"react-resizable": "3.0.5",
"react-responsive-carousel": "^3.2.23",
"react-rnd": "10.4.13",
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"react-router-dom-v5-compat": "^6.26.1",
@@ -416,6 +417,7 @@
"react-virtualized-auto-sizer": "1.0.26",
"react-window": "1.8.11",
"react-window-infinite-loader": "1.0.10",
"react-zoom-pan-pinch": "3.6.1",
"reduce-reducers": "^1.0.4",
"redux": "5.0.1",
"redux-thunk": "3.1.0",

View File

@@ -190,6 +190,8 @@ func (hs *HTTPServer) registerRoutes() {
}
r.Get("/explore", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index)
r.Get("/atlas", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index)
r.Get("/atlas/*", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index)
r.Get("/drilldown", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index)
r.Get("/playlists/", reqSignedIn, hs.Index)
@@ -502,6 +504,9 @@ func (hs *HTTPServer) registerRoutes() {
// Playlist
hs.registerPlaylistAPI(apiRoute)
// Atlas
hs.registerExploreMapAPI(apiRoute, hs.exploreMapService)
// Search
apiRoute.Get("/search/sorting", routing.Wrap(hs.ListSortOptions))
apiRoute.Get("/search/", routing.Wrap(hs.Search))

228
pkg/api/exploremap.go Normal file
View File

@@ -0,0 +1,228 @@
package api
import (
"errors"
"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("/atlas", 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 /atlas explore-maps listExploreMaps
//
// Get atlas maps.
//
// Responses:
// 200: listExploreMapsResponse
// 500: internalServerError
func (hs *HTTPServer) listExploreMaps(c *contextmodel.ReqContext) response.Response {
query := &exploremap.GetExploreMapsQuery{
OrgID: c.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 atlas maps", err)
}
return response.JSON(http.StatusOK, maps)
}
// swagger:route GET /atlas/{uid} explore-maps getExploreMap
//
// Get atlas 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.GetOrgID(),
}
m, err := hs.exploreMapService.Get(c.Req.Context(), query)
if err != nil {
if errors.Is(err, exploremap.ErrExploreMapNotFound) {
return response.Error(http.StatusNotFound, "Atlas map not found", err)
}
return response.Error(http.StatusInternalServerError, "Failed to get atlas map", err)
}
return response.JSON(http.StatusOK, m)
}
// swagger:route POST /atlas explore-maps createExploreMap
//
// Create atlas 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.GetOrgID()
cmd.CreatedBy = c.UserID
m, err := hs.exploreMapService.Create(c.Req.Context(), &cmd)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to create atlas map", err)
}
return response.JSON(http.StatusOK, m)
}
// swagger:route PUT /atlas/{uid} explore-maps updateExploreMap
//
// Update atlas 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.GetOrgID()
cmd.UpdatedBy = c.UserID
m, err := hs.exploreMapService.Update(c.Req.Context(), &cmd)
if err != nil {
if errors.Is(err, exploremap.ErrExploreMapNotFound) {
return response.Error(http.StatusNotFound, "Atlas map not found", err)
}
return response.Error(http.StatusInternalServerError, "Failed to update atlas map", err)
}
return response.JSON(http.StatusOK, m)
}
// swagger:route DELETE /atlas/{uid} explore-maps deleteExploreMap
//
// Delete atlas 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.GetOrgID(),
}
err := hs.exploreMapService.Delete(c.Req.Context(), cmd)
if err != nil {
if errors.Is(err, exploremap.ErrExploreMapNotFound) {
return response.Error(http.StatusNotFound, "Atlas map not found", err)
}
return response.Error(http.StatusInternalServerError, "Failed to delete atlas map", err)
}
return response.JSON(http.StatusOK, map[string]string{"message": "Atlas map deleted"})
}

View File

@@ -64,6 +64,7 @@ import (
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/datasources/guardian"
"github.com/grafana/grafana/pkg/services/encryption"
"github.com/grafana/grafana/pkg/services/exploremap"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/hooks"
@@ -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,

View File

@@ -91,6 +91,7 @@ import (
"github.com/grafana/grafana/pkg/services/dsquerierclient"
"github.com/grafana/grafana/pkg/services/encryption"
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
"github.com/grafana/grafana/pkg/services/exploremap/exploremapimpl"
"github.com/grafana/grafana/pkg/services/extsvcauth"
extsvcreg "github.com/grafana/grafana/pkg/services/extsvcauth/registry"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -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,

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,177 @@
package crdt
import (
"encoding/json"
"time"
)
// HLCTimestamp represents a hybrid logical clock timestamp
type HLCTimestamp struct {
LogicalTime int64 `json:"logicalTime"`
WallTime int64 `json:"wallTime"` // milliseconds since epoch
NodeID string `json:"nodeId"`
}
// HybridLogicalClock manages hybrid logical time for a node
type HybridLogicalClock struct {
logicalTime int64
lastWallTime int64
nodeID string
}
// NewHybridLogicalClock creates a new HLC with the given node ID
func NewHybridLogicalClock(nodeID string) *HybridLogicalClock {
return &HybridLogicalClock{
logicalTime: 0,
lastWallTime: time.Now().UnixMilli(),
nodeID: nodeID,
}
}
// Tick advances the clock for a local event and returns a new timestamp
func (h *HybridLogicalClock) Tick() HLCTimestamp {
now := time.Now().UnixMilli()
if now > h.lastWallTime {
// Physical clock advanced - use it
h.lastWallTime = now
h.logicalTime = 0
} else {
// Physical clock hasn't advanced - increment logical component
h.logicalTime++
}
return h.Clone()
}
// Update updates the clock based on a received timestamp from another node
func (h *HybridLogicalClock) Update(received HLCTimestamp) {
now := time.Now().UnixMilli()
maxWall := max(h.lastWallTime, received.WallTime, now)
if maxWall == h.lastWallTime && maxWall == received.WallTime {
// Same wall time - take max logical time and increment
h.logicalTime = max(h.logicalTime, received.LogicalTime, 0) + 1
} else if maxWall == received.WallTime {
// Received timestamp has newer wall time
h.lastWallTime = maxWall
h.logicalTime = received.LogicalTime + 1
} else {
// Our wall time or physical clock is newer
h.lastWallTime = maxWall
h.logicalTime = 0
}
}
// Clone returns a copy of the current timestamp
func (h *HybridLogicalClock) Clone() HLCTimestamp {
return HLCTimestamp{
LogicalTime: h.logicalTime,
WallTime: h.lastWallTime,
NodeID: h.nodeID,
}
}
// Now returns the current timestamp without advancing the clock
func (h *HybridLogicalClock) Now() HLCTimestamp {
return h.Clone()
}
// GetNodeID returns the node ID
func (h *HybridLogicalClock) GetNodeID() string {
return h.nodeID
}
// CompareHLC compares two HLC timestamps
// Returns: -1 if a < b, 0 if a == b, 1 if a > b
func CompareHLC(a, b HLCTimestamp) int {
// First compare wall time
if a.WallTime != b.WallTime {
if a.WallTime < b.WallTime {
return -1
}
return 1
}
// Then compare logical time
if a.LogicalTime != b.LogicalTime {
if a.LogicalTime < b.LogicalTime {
return -1
}
return 1
}
// Finally compare node IDs for deterministic tie-breaking
if a.NodeID < b.NodeID {
return -1
} else if a.NodeID > b.NodeID {
return 1
}
return 0
}
// HappensBefore checks if timestamp a happened before timestamp b
func HappensBefore(a, b HLCTimestamp) bool {
return CompareHLC(a, b) < 0
}
// HappensAfter checks if timestamp a happened after timestamp b
func HappensAfter(a, b HLCTimestamp) bool {
return CompareHLC(a, b) > 0
}
// TimestampEquals checks if two timestamps are equal
func TimestampEquals(a, b HLCTimestamp) bool {
return CompareHLC(a, b) == 0
}
// MaxTimestamp returns the later of two timestamps
func MaxTimestamp(a, b HLCTimestamp) HLCTimestamp {
if CompareHLC(a, b) >= 0 {
return a
}
return b
}
// MarshalJSON implements json.Marshaler
func (t HLCTimestamp) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
LogicalTime int64 `json:"logicalTime"`
WallTime int64 `json:"wallTime"`
NodeID string `json:"nodeId"`
}{
LogicalTime: t.LogicalTime,
WallTime: t.WallTime,
NodeID: t.NodeID,
})
}
// UnmarshalJSON implements json.Unmarshaler
func (t *HLCTimestamp) UnmarshalJSON(data []byte) error {
var tmp struct {
LogicalTime int64 `json:"logicalTime"`
WallTime int64 `json:"wallTime"`
NodeID string `json:"nodeId"`
}
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
t.LogicalTime = tmp.LogicalTime
t.WallTime = tmp.WallTime
t.NodeID = tmp.NodeID
return nil
}
func max(a, b, c int64) int64 {
result := a
if b > result {
result = b
}
if c > result {
result = c
}
return result
}

View File

@@ -0,0 +1,82 @@
package crdt
import (
"encoding/json"
)
// LWWRegister represents a Last-Write-Wins Register CRDT
type LWWRegister struct {
value interface{}
timestamp HLCTimestamp
}
// LWWRegisterJSON is the JSON representation of LWWRegister
type LWWRegisterJSON struct {
Value interface{} `json:"value"`
Timestamp HLCTimestamp `json:"timestamp"`
}
// NewLWWRegister creates a new LWW-Register with an initial value and timestamp
func NewLWWRegister(value interface{}, timestamp HLCTimestamp) *LWWRegister {
return &LWWRegister{
value: value,
timestamp: timestamp,
}
}
// Set sets the register value if the new timestamp is greater than current
// Returns true if the value was updated, false if update was ignored
func (r *LWWRegister) Set(value interface{}, timestamp HLCTimestamp) bool {
// Only update if new timestamp is strictly greater
if CompareHLC(timestamp, r.timestamp) > 0 {
r.value = value
r.timestamp = timestamp
return true
}
return false
}
// Get returns the current value
func (r *LWWRegister) Get() interface{} {
return r.value
}
// GetTimestamp returns the current timestamp
func (r *LWWRegister) GetTimestamp() HLCTimestamp {
return r.timestamp
}
// Merge merges another LWW-Register into this one
// Keeps the value with the highest timestamp
// Returns true if this register's value was updated
func (r *LWWRegister) Merge(other *LWWRegister) bool {
return r.Set(other.value, other.timestamp)
}
// Clone creates a copy of the register
func (r *LWWRegister) Clone() *LWWRegister {
return &LWWRegister{
value: r.value,
timestamp: r.timestamp,
}
}
// MarshalJSON implements json.Marshaler
func (r *LWWRegister) MarshalJSON() ([]byte, error) {
return json.Marshal(LWWRegisterJSON{
Value: r.value,
Timestamp: r.timestamp,
})
}
// UnmarshalJSON implements json.Unmarshaler
func (r *LWWRegister) UnmarshalJSON(data []byte) error {
var tmp LWWRegisterJSON
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
r.value = tmp.Value
r.timestamp = tmp.Timestamp
return nil
}

View File

@@ -0,0 +1,165 @@
package crdt
import (
"encoding/json"
)
// OperationType represents the type of CRDT operation
type OperationType string
const (
OpAddPanel OperationType = "add-panel"
OpRemovePanel OperationType = "remove-panel"
OpUpdatePanelPosition OperationType = "update-panel-position"
OpUpdatePanelSize OperationType = "update-panel-size"
OpUpdatePanelZIndex OperationType = "update-panel-zindex"
OpUpdatePanelExplore OperationType = "update-panel-explore-state"
OpUpdateTitle OperationType = "update-title"
OpAddComment OperationType = "add-comment"
OpRemoveComment OperationType = "remove-comment"
OpBatch OperationType = "batch"
)
// Operation represents a CRDT operation
type Operation struct {
Type OperationType `json:"type"`
MapUID string `json:"mapUid"`
OperationID string `json:"operationId"`
Timestamp HLCTimestamp `json:"timestamp"`
NodeID string `json:"nodeId"`
Payload json.RawMessage `json:"payload"`
}
// AddPanelPayload represents the payload for add-panel operation
type AddPanelPayload struct {
PanelID string `json:"panelId"`
ExploreID string `json:"exploreId"`
Position PanelPosition `json:"position"`
}
// RemovePanelPayload represents the payload for remove-panel operation
type RemovePanelPayload struct {
PanelID string `json:"panelId"`
ObservedTags []string `json:"observedTags"`
}
// UpdatePanelPositionPayload represents the payload for update-panel-position operation
type UpdatePanelPositionPayload struct {
PanelID string `json:"panelId"`
X float64 `json:"x"`
Y float64 `json:"y"`
}
// UpdatePanelSizePayload represents the payload for update-panel-size operation
type UpdatePanelSizePayload struct {
PanelID string `json:"panelId"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// UpdatePanelZIndexPayload represents the payload for update-panel-zindex operation
type UpdatePanelZIndexPayload struct {
PanelID string `json:"panelId"`
ZIndex int64 `json:"zIndex"`
}
// UpdatePanelExploreStatePayload represents the payload for update-panel-explore-state operation
type UpdatePanelExploreStatePayload struct {
PanelID string `json:"panelId"`
ExploreState interface{} `json:"exploreState"`
}
// UpdateTitlePayload represents the payload for update-title operation
type UpdateTitlePayload struct {
Title string `json:"title"`
}
// CommentData represents a comment with text, username, and timestamp
type CommentData struct {
Text string `json:"text"`
Username string `json:"username"`
Timestamp int64 `json:"timestamp"`
}
// AddCommentPayload represents the payload for add-comment operation
type AddCommentPayload struct {
CommentID string `json:"commentId"`
Comment CommentData `json:"comment"`
}
// RemoveCommentPayload represents the payload for remove-comment operation
type RemoveCommentPayload struct {
CommentID string `json:"commentId"`
ObservedTags []string `json:"observedTags"`
}
// BatchPayload represents the payload for batch operation
type BatchPayload struct {
Operations []Operation `json:"operations"`
}
// PanelPosition represents the position and size of a panel
type PanelPosition struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// ParsePayload parses the operation payload into the appropriate type
func (op *Operation) ParsePayload() (interface{}, error) {
switch op.Type {
case OpAddPanel:
var payload AddPanelPayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
case OpRemovePanel:
var payload RemovePanelPayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
case OpUpdatePanelPosition:
var payload UpdatePanelPositionPayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
case OpUpdatePanelSize:
var payload UpdatePanelSizePayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
case OpUpdatePanelZIndex:
var payload UpdatePanelZIndexPayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
case OpUpdatePanelExplore:
var payload UpdatePanelExploreStatePayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
case OpUpdateTitle:
var payload UpdateTitlePayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
case OpAddComment:
var payload AddCommentPayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
case OpRemoveComment:
var payload RemoveCommentPayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
case OpBatch:
var payload BatchPayload
err := json.Unmarshal(op.Payload, &payload)
return payload, err
default:
return nil, nil
}
}

View File

@@ -0,0 +1,215 @@
package crdt
import (
"encoding/json"
)
// ORSet represents an Observed-Remove Set CRDT
type ORSet struct {
adds map[string]map[string]bool // element -> set of tags
removes map[string]bool // set of removed tags
}
// ORSetJSON is the JSON representation of ORSet
type ORSetJSON struct {
Adds map[string][]string `json:"adds"`
Removes []string `json:"removes"`
}
// NewORSet creates a new empty OR-Set
func NewORSet() *ORSet {
return &ORSet{
adds: make(map[string]map[string]bool),
removes: make(map[string]bool),
}
}
// Add adds an element to the set with a unique tag
func (s *ORSet) Add(element, tag string) {
if s.adds[element] == nil {
s.adds[element] = make(map[string]bool)
}
s.adds[element][tag] = true
}
// Remove removes an element from the set
// Only removes the specific tags that were observed
func (s *ORSet) Remove(element string, observedTags []string) {
for _, tag := range observedTags {
s.removes[tag] = true
}
// Clean up the element's tags
if elementTags, exists := s.adds[element]; exists {
for _, tag := range observedTags {
delete(elementTags, tag)
}
// If no tags remain, remove the element entry
if len(elementTags) == 0 {
delete(s.adds, element)
}
}
}
// Contains checks if an element is in the set
// Element is present if it has at least one non-removed tag
func (s *ORSet) Contains(element string) bool {
tags, exists := s.adds[element]
if !exists || len(tags) == 0 {
return false
}
// Element is present if it has at least one tag that hasn't been removed
for tag := range tags {
if !s.removes[tag] {
return true
}
}
return false
}
// GetTags returns all tags for an element (including removed ones)
func (s *ORSet) GetTags(element string) []string {
tags, exists := s.adds[element]
if !exists {
return []string{}
}
result := make([]string, 0, len(tags))
for tag := range tags {
result = append(result, tag)
}
return result
}
// Values returns all elements currently in the set
func (s *ORSet) Values() []string {
result := make([]string, 0, len(s.adds))
for element, tags := range s.adds {
// Include element if it has at least one non-removed tag
for tag := range tags {
if !s.removes[tag] {
result = append(result, element)
break
}
}
}
return result
}
// Size returns the number of elements in the set
func (s *ORSet) Size() int {
return len(s.Values())
}
// IsEmpty checks if the set is empty
func (s *ORSet) IsEmpty() bool {
return s.Size() == 0
}
// Merge merges another OR-Set into this one
// Takes the union of all adds and removes
func (s *ORSet) Merge(other *ORSet) {
// Merge adds (union of all tags)
for element, otherTags := range other.adds {
if s.adds[element] == nil {
s.adds[element] = make(map[string]bool)
}
for tag := range otherTags {
s.adds[element][tag] = true
}
}
// Merge removes (union of all removed tags)
for tag := range other.removes {
s.removes[tag] = true
}
// Clean up elements with all tags removed
for element, tags := range s.adds {
hasLiveTag := false
for tag := range tags {
if !s.removes[tag] {
hasLiveTag = true
break
}
}
if !hasLiveTag {
delete(s.adds, element)
}
}
}
// Clone creates a deep copy of the OR-Set
func (s *ORSet) Clone() *ORSet {
clone := NewORSet()
// Deep copy adds
for element, tags := range s.adds {
clone.adds[element] = make(map[string]bool)
for tag := range tags {
clone.adds[element][tag] = true
}
}
// Deep copy removes
for tag := range s.removes {
clone.removes[tag] = true
}
return clone
}
// Clear removes all elements from the set
func (s *ORSet) Clear() {
s.adds = make(map[string]map[string]bool)
s.removes = make(map[string]bool)
}
// MarshalJSON implements json.Marshaler
func (s *ORSet) MarshalJSON() ([]byte, error) {
adds := make(map[string][]string)
for element, tags := range s.adds {
tagList := make([]string, 0, len(tags))
for tag := range tags {
tagList = append(tagList, tag)
}
adds[element] = tagList
}
removes := make([]string, 0, len(s.removes))
for tag := range s.removes {
removes = append(removes, tag)
}
return json.Marshal(ORSetJSON{
Adds: adds,
Removes: removes,
})
}
// UnmarshalJSON implements json.Unmarshaler
func (s *ORSet) UnmarshalJSON(data []byte) error {
var tmp ORSetJSON
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
s.adds = make(map[string]map[string]bool)
for element, tags := range tmp.Adds {
s.adds[element] = make(map[string]bool)
for _, tag := range tags {
s.adds[element][tag] = true
}
}
s.removes = make(map[string]bool)
for _, tag := range tmp.Removes {
s.removes[tag] = true
}
return nil
}

View File

@@ -0,0 +1,134 @@
package crdt
import (
"encoding/json"
)
// PNCounter represents a Positive-Negative Counter CRDT
type PNCounter struct {
increments map[string]int64 // nodeId -> count
decrements map[string]int64 // nodeId -> count
}
// PNCounterJSON is the JSON representation of PNCounter
type PNCounterJSON struct {
Increments map[string]int64 `json:"increments"`
Decrements map[string]int64 `json:"decrements"`
}
// NewPNCounter creates a new PN-Counter
func NewPNCounter() *PNCounter {
return &PNCounter{
increments: make(map[string]int64),
decrements: make(map[string]int64),
}
}
// Increment increments the counter for a specific node
func (c *PNCounter) Increment(nodeID string, delta int64) {
if delta < 0 {
panic("delta must be non-negative for increment")
}
c.increments[nodeID] += delta
}
// Decrement decrements the counter for a specific node
func (c *PNCounter) Decrement(nodeID string, delta int64) {
if delta < 0 {
panic("delta must be non-negative for decrement")
}
c.decrements[nodeID] += delta
}
// Value returns the current value of the counter
// Value = sum of all increments - sum of all decrements
func (c *PNCounter) Value() int64 {
var sum int64 = 0
// Add all increments
for _, count := range c.increments {
sum += count
}
// Subtract all decrements
for _, count := range c.decrements {
sum -= count
}
return sum
}
// Next gets the next value and increments the counter for a node
// This is useful for allocating sequential IDs (like z-indices)
func (c *PNCounter) Next(nodeID string) int64 {
nextValue := c.Value() + 1
c.Increment(nodeID, 1)
return nextValue
}
// Merge merges another PN-Counter into this one
// Takes the maximum value for each node's counters
func (c *PNCounter) Merge(other *PNCounter) {
// Merge increments (take max for each node)
for nodeID, count := range other.increments {
if current, exists := c.increments[nodeID]; !exists || count > current {
c.increments[nodeID] = count
}
}
// Merge decrements (take max for each node)
for nodeID, count := range other.decrements {
if current, exists := c.decrements[nodeID]; !exists || count > current {
c.decrements[nodeID] = count
}
}
}
// Clone creates a copy of the counter
func (c *PNCounter) Clone() *PNCounter {
clone := NewPNCounter()
for nodeID, count := range c.increments {
clone.increments[nodeID] = count
}
for nodeID, count := range c.decrements {
clone.decrements[nodeID] = count
}
return clone
}
// Reset resets the counter to zero
func (c *PNCounter) Reset() {
c.increments = make(map[string]int64)
c.decrements = make(map[string]int64)
}
// MarshalJSON implements json.Marshaler
func (c *PNCounter) MarshalJSON() ([]byte, error) {
return json.Marshal(PNCounterJSON{
Increments: c.increments,
Decrements: c.decrements,
})
}
// UnmarshalJSON implements json.Unmarshaler
func (c *PNCounter) UnmarshalJSON(data []byte) error {
var tmp PNCounterJSON
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
c.increments = tmp.Increments
if c.increments == nil {
c.increments = make(map[string]int64)
}
c.decrements = tmp.Decrements
if c.decrements == nil {
c.decrements = make(map[string]int64)
}
return nil
}

View File

@@ -0,0 +1,113 @@
package exploremapimpl
import (
"context"
"time"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/exploremap"
"github.com/grafana/grafana/pkg/services/exploremap/realtime"
"github.com/grafana/grafana/pkg/services/live"
)
type Service struct {
store store
tracer tracing.Tracer
hub *realtime.OperationHub
}
var _ exploremap.Service = &Service{}
// storeAdapter adapts the internal store interface to the realtime Store interface
type storeAdapter struct {
store store
}
func (s *storeAdapter) Update(ctx context.Context, cmd *exploremap.UpdateExploreMapCommand) (*exploremap.ExploreMapDTO, error) {
return s.store.Update(ctx, cmd)
}
func (s *storeAdapter) Get(ctx context.Context, query *exploremap.GetExploreMapByUIDQuery) (*exploremap.ExploreMapDTO, error) {
// Get returns ExploreMap, but we need ExploreMapDTO
mapData, err := s.store.Get(ctx, query)
if err != nil {
return nil, err
}
return &exploremap.ExploreMapDTO{
UID: mapData.UID,
Title: mapData.Title,
Data: mapData.Data,
CreatedBy: mapData.CreatedBy,
UpdatedBy: mapData.UpdatedBy,
CreatedAt: mapData.CreatedAt,
UpdatedAt: mapData.UpdatedAt,
}, nil
}
func ProvideService(db db.DB, tracer tracing.Tracer, liveService *live.GrafanaLive) exploremap.Service {
store := &sqlStore{
db: db,
}
// Create adapter for realtime hub
adapter := &storeAdapter{store: store}
// Create operation hub for CRDT synchronization
hub := realtime.NewOperationHub(liveService, adapter)
// Register channel handler with Grafana Live
channelHandler := realtime.NewExploreMapChannelHandler(hub)
liveService.GrafanaScope.Features["explore-map"] = channelHandler
// Start background snapshot worker (saves CRDT state to SQL every 30 seconds)
go hub.StartSnapshotWorker(context.Background(), 30*time.Second)
return &Service{
tracer: tracer,
store: store,
hub: hub,
}
}
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)
}

View File

@@ -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
}

View File

@@ -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
}

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
}

View File

@@ -0,0 +1,150 @@
package realtime
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/exploremap/crdt"
"github.com/grafana/grafana/pkg/services/live/model"
)
// ExploreMapChannelHandler handles Grafana Live channels for Explore Maps
type ExploreMapChannelHandler struct {
hub *OperationHub
}
// NewExploreMapChannelHandler creates a new channel handler
func NewExploreMapChannelHandler(hub *OperationHub) *ExploreMapChannelHandler {
return &ExploreMapChannelHandler{
hub: hub,
}
}
// OnSubscribe is called when a client subscribes to the channel
func (h *ExploreMapChannelHandler) OnSubscribe(ctx context.Context, user identity.Requester, e model.SubscribeEvent) (model.SubscribeReply, backend.SubscribeStreamStatus, error) {
// Extract map UID from channel path
// Channel format: grafana/explore-map/{mapUid}
mapUID := extractMapUID(e.Channel)
if mapUID == "" {
return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil
}
// TODO: Check access permissions
// For now, allow all authenticated users
// Get current CRDT state
state, err := h.hub.GetState(ctx, mapUID)
if err != nil {
return model.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil
}
// Serialize state as initial data
state.mu.RLock()
stateData := map[string]interface{}{
"title": state.Title,
"panels": state.Panels,
"zIndex": state.ZIndex,
}
state.mu.RUnlock()
data, err := json.Marshal(stateData)
if err != nil {
return model.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, err
}
return model.SubscribeReply{
Data: data,
}, backend.SubscribeStreamStatusOK, nil
}
// messageType represents the type of message being sent
type messageType string
const (
MessageTypeCursorUpdate messageType = "cursor_update"
MessageTypeCursorLeave messageType = "cursor_leave"
MessageTypeViewportUpdate messageType = "viewport_update"
)
// OnPublish is called when a client publishes to the channel
func (h *ExploreMapChannelHandler) OnPublish(ctx context.Context, user identity.Requester, e model.PublishEvent) (model.PublishReply, backend.PublishStreamStatus, error) {
// First, peek at the message to see if it has a "type" field that matches cursor message types
var msgPeek struct {
Type messageType `json:"type"`
}
if err := json.Unmarshal(e.Data, &msgPeek); err == nil {
// Check if this is a cursor/viewport message
if msgPeek.Type == MessageTypeCursorUpdate || msgPeek.Type == MessageTypeCursorLeave || msgPeek.Type == MessageTypeViewportUpdate {
// This is a cursor message, enrich it with user info and broadcast
var msg struct {
Type messageType `json:"type"`
SessionID string `json:"sessionId"`
UserID string `json:"userId"`
UserName string `json:"userName"`
Timestamp int64 `json:"timestamp"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(e.Data, &msg); err != nil {
logger.Warn("Failed to parse cursor message", "error", err)
return model.PublishReply{}, backend.PublishStreamStatusPermissionDenied, fmt.Errorf("invalid cursor message format")
}
// Enrich with user info
msg.UserID = user.GetRawIdentifier()
msg.UserName = user.GetName()
if msg.UserName == "" {
msg.UserName = user.GetLogin()
}
msg.Timestamp = time.Now().UnixMilli()
// Marshal enriched message
enrichedData, err := json.Marshal(msg)
if err != nil {
logger.Warn("Failed to marshal enriched cursor message", "error", err)
return model.PublishReply{}, backend.PublishStreamStatusPermissionDenied, fmt.Errorf("internal error")
}
// Broadcast to all subscribers
return model.PublishReply{
Data: enrichedData,
}, backend.PublishStreamStatusOK, nil
}
}
// Not a cursor message, treat as CRDT operation
var op crdt.Operation
if err := json.Unmarshal(e.Data, &op); err != nil {
logger.Warn("Failed to parse operation", "error", err)
return model.PublishReply{}, backend.PublishStreamStatusPermissionDenied, fmt.Errorf("failed to parse operation: %w", err)
}
// TODO: Validate user has permission to modify the map
// Process operation through hub
if err := h.hub.HandleOperation(ctx, op); err != nil {
logger.Warn("Failed to handle operation", "error", err)
return model.PublishReply{}, backend.PublishStreamStatusPermissionDenied, err
}
return model.PublishReply{}, backend.PublishStreamStatusOK, nil
}
// GetHandlerForPath returns the channel handler for a given path
func (h *ExploreMapChannelHandler) GetHandlerForPath(path string) (model.ChannelHandler, error) {
return h, nil
}
// extractMapUID extracts the map UID from a channel path
// Channel format: grafana/explore-map/{mapUid}
func extractMapUID(channel string) string {
parts := strings.Split(channel, "/")
if len(parts) >= 3 && parts[1] == "explore-map" {
return parts[2]
}
return ""
}

View File

@@ -0,0 +1,253 @@
package realtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/exploremap"
"github.com/grafana/grafana/pkg/services/exploremap/crdt"
"github.com/grafana/grafana/pkg/services/live"
)
var logger = log.New("exploremap.realtime")
// Store interface for saving map snapshots
type Store interface {
Update(ctx context.Context, cmd *exploremap.UpdateExploreMapCommand) (*exploremap.ExploreMapDTO, error)
Get(ctx context.Context, query *exploremap.GetExploreMapByUIDQuery) (*exploremap.ExploreMapDTO, error)
}
// OperationHub manages real-time CRDT operations
type OperationHub struct {
liveService *live.GrafanaLive
store Store
states *StateCache
}
// StateCache holds in-memory CRDT states for active maps
type StateCache struct {
states map[string]*MapState
mu sync.RWMutex
}
// MapState represents the CRDT state for a single map
type MapState struct {
UID string
OrgID int64
Title *crdt.LWWRegister
Panels *crdt.ORSet
ZIndex *crdt.PNCounter
Updated time.Time
mu sync.RWMutex
}
// NewOperationHub creates a new operation hub
func NewOperationHub(liveService *live.GrafanaLive, store Store) *OperationHub {
return &OperationHub{
liveService: liveService,
store: store,
states: &StateCache{
states: make(map[string]*MapState),
},
}
}
// HandleOperation processes an incoming CRDT operation
func (h *OperationHub) HandleOperation(ctx context.Context, op crdt.Operation) error {
// Get or create state
state, err := h.states.GetOrCreate(op.MapUID)
if err != nil {
return fmt.Errorf("failed to get state: %w", err)
}
state.mu.Lock()
defer state.mu.Unlock()
// Apply operation to CRDT state
if err := h.applyOperation(state, op); err != nil {
return fmt.Errorf("failed to apply operation: %w", err)
}
state.Updated = time.Now()
// Broadcast to all connected clients
// Include grafana scope prefix to match subscription channel
channel := fmt.Sprintf("grafana/explore-map/%s", op.MapUID)
data, err := json.Marshal(op)
if err != nil {
return fmt.Errorf("failed to marshal operation: %w", err)
}
// TODO: Get actual orgID from map state or context
// For now, use 1 as the orgID (Grafana default org)
orgID := int64(1)
if err := h.liveService.Publish(orgID, channel, data); err != nil {
logger.Warn("Failed to broadcast operation", "error", err, "mapUid", op.MapUID, "channel", channel)
return fmt.Errorf("failed to broadcast operation: %w", err)
}
return nil
}
// applyOperation applies a CRDT operation to the state
func (h *OperationHub) applyOperation(state *MapState, op crdt.Operation) error {
payload, err := op.ParsePayload()
if err != nil {
return fmt.Errorf("failed to parse payload: %w", err)
}
switch op.Type {
case crdt.OpAddPanel:
p := payload.(crdt.AddPanelPayload)
state.Panels.Add(p.PanelID, op.OperationID)
// Allocate z-index
state.ZIndex.Next(op.NodeID)
case crdt.OpRemovePanel:
p := payload.(crdt.RemovePanelPayload)
state.Panels.Remove(p.PanelID, p.ObservedTags)
case crdt.OpUpdateTitle:
p := payload.(crdt.UpdateTitlePayload)
state.Title.Set(p.Title, op.Timestamp)
case crdt.OpBatch:
p := payload.(crdt.BatchPayload)
for _, subOp := range p.Operations {
if err := h.applyOperation(state, subOp); err != nil {
return err
}
}
case crdt.OpUpdatePanelPosition,
crdt.OpUpdatePanelSize,
crdt.OpUpdatePanelZIndex,
crdt.OpUpdatePanelExplore,
crdt.OpAddComment,
crdt.OpRemoveComment:
// These operation types don't need special handling in the hub
// They're applied at the client level
}
return nil
}
// GetState returns the current CRDT state for a map
func (h *OperationHub) GetState(ctx context.Context, mapUID string) (*MapState, error) {
return h.states.GetOrCreate(mapUID)
}
// SnapshotState persists the current CRDT state to the database
func (h *OperationHub) SnapshotState(ctx context.Context, mapUID string) error {
state, err := h.states.Get(mapUID)
if err != nil {
return err
}
state.mu.RLock()
defer state.mu.RUnlock()
// Skip if OrgID not set - map may not exist in database yet
if state.OrgID == 0 {
return exploremap.ErrExploreMapNotFound
}
orgID := state.OrgID
// Serialize state to JSON
stateData := map[string]interface{}{
"title": state.Title,
"panels": state.Panels,
"zIndex": state.ZIndex,
}
data, err := json.Marshal(stateData)
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
// Update in database
_, err = h.store.Update(ctx, &exploremap.UpdateExploreMapCommand{
UID: mapUID,
OrgID: orgID,
Data: string(data),
})
return err
}
// StartSnapshotWorker starts a background worker that periodically snapshots states
func (h *OperationHub) StartSnapshotWorker(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
h.snapshotAll(ctx)
case <-ctx.Done():
return
}
}
}
func (h *OperationHub) snapshotAll(ctx context.Context) {
h.states.mu.RLock()
mapUIDs := make([]string, 0, len(h.states.states))
for uid := range h.states.states {
mapUIDs = append(mapUIDs, uid)
}
h.states.mu.RUnlock()
for _, uid := range mapUIDs {
if err := h.SnapshotState(ctx, uid); err != nil {
// Ignore "not found" errors - map may have been deleted or not yet created
if !errors.Is(err, exploremap.ErrExploreMapNotFound) {
logger.Warn("Failed to snapshot state", "error", err, "mapUid", uid)
}
}
}
}
// StateCache methods
func (sc *StateCache) Get(mapUID string) (*MapState, error) {
sc.mu.RLock()
defer sc.mu.RUnlock()
state, exists := sc.states[mapUID]
if !exists {
return nil, fmt.Errorf("state not found for map: %s", mapUID)
}
return state, nil
}
func (sc *StateCache) GetOrCreate(mapUID string) (*MapState, error) {
sc.mu.Lock()
defer sc.mu.Unlock()
state, exists := sc.states[mapUID]
if !exists {
// Create new state
state = &MapState{
UID: mapUID,
Title: crdt.NewLWWRegister("Untitled Map", crdt.HLCTimestamp{}),
Panels: crdt.NewORSet(),
ZIndex: crdt.NewPNCounter(),
Updated: time.Now(),
}
sc.states[mapUID] = state
}
return state, nil
}
func (sc *StateCache) Remove(mapUID string) {
sc.mu.Lock()
defer sc.mu.Unlock()
delete(sc.states, mapUID)
}

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
}

View File

@@ -41,6 +41,7 @@ const (
NavIDRoot = "root"
NavIDDashboards = "dashboards/browse"
NavIDExplore = "explore"
NavIDExploreMap = "explore-map"
NavIDDrilldown = "drilldown"
NavIDAdaptiveTelemetry = "adaptive-telemetry"
NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node

View File

@@ -134,6 +134,17 @@ func (s *ServiceImpl) GetNavTree(c *contextmodel.ReqContext, prefs *pref.Prefere
})
}
if s.cfg.ExploreEnabled && hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) {
treeRoot.AddSection(&navtree.NavLink{
Text: "Atlas",
Id: navtree.NavIDExploreMap,
SubTitle: "Explore your data on collaborative canvases",
Icon: "globe",
SortWeight: navtree.WeightExplore + 1,
Url: s.cfg.AppSubURL + "/atlas",
})
}
if hasAccess(ac.EvalPermission(ac.ActionDatasourcesExplore)) {
treeRoot.AddSection(&navtree.NavLink{
Text: "Drilldown",

View File

@@ -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]))
}

View File

@@ -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)

View File

@@ -15,6 +15,7 @@ import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/s
import dashboardReducers from 'app/features/dashboard/state/reducers';
import dataSourcesReducers from 'app/features/datasources/state/reducers';
import exploreReducers from 'app/features/explore/state/main';
import exploreMapReducers from 'app/features/explore-map/state/reducers';
import invitesReducers from 'app/features/invites/state/reducers';
import importDashboardReducers from 'app/features/manage-dashboards/state/reducers';
import organizationReducers from 'app/features/org/state/reducers';
@@ -36,6 +37,7 @@ const rootReducers = {
...teamsReducers,
...dashboardReducers,
...exploreReducers,
...exploreMapReducers,
...dataSourcesReducers,
...usersReducers,
...serviceAccountsReducer,

View File

@@ -60,6 +60,8 @@ export function getNavTitle(navId: string | undefined) {
return t('nav.scenes.title', 'Scenes');
case 'explore':
return t('nav.explore.title', 'Explore');
case 'explore-map':
return t('nav.explore-map.title', 'Atlas');
case 'drilldown':
return t('nav.drilldown.title', 'Drilldown');
case 'alerting':

View File

@@ -0,0 +1,371 @@
/* eslint-disable @grafana/i18n/no-untranslated-strings */
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,
UsersIndicator,
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 { useMapActiveUsers } from './hooks/useMapActiveUsers';
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 atlas maps');
console.error('Failed to load atlas maps:', err);
} finally {
setLoading(false);
}
};
const handleCreateNew = async () => {
try {
setCreatingNew(true);
const newMap = await exploreMapApi.createExploreMap({
title: 'New Atlas',
data: initialExploreMapState,
});
// Navigate to the new map
window.location.href = `/atlas/${newMap.uid}`;
} catch (err) {
console.error('Failed to create atlas:', err);
setError(err instanceof Error ? err.message : 'Failed to create atlas');
setCreatingNew(false);
}
};
const handleDelete = async (uid: string) => {
try {
await exploreMapApi.deleteExploreMap(uid);
setDeleteConfirmUid(null);
loadMaps();
} catch (err) {
console.error('Failed to delete atlas:', err);
setError(err instanceof Error ? err.message : 'Failed to delete atlas');
}
};
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">Atlas</Trans>
</h1>
<div className={styles.header}>
<div className={styles.headerContent}>
<h2 className={styles.pageTitle}>Atlas</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 atlas 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 atlas maps yet</p>
<p className={styles.emptyStateText}>
Create your first atlas 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) => (
<MapCard key={map.uid} map={map} onDelete={() => setDeleteConfirmUid(map.uid)} formatDate={formatDate} />
))}
</div>
)}
{deleteConfirmUid && mapToDelete && (
<ConfirmModal
isOpen={true}
title="Delete atlas"
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>
);
}
interface MapCardProps {
map: ExploreMapListItem;
onDelete: () => void;
formatDate: (dateStr: string) => string;
}
function MapCard({ map, onDelete, formatDate }: MapCardProps) {
const styles = useStyles2(getStyles);
const activeUsers = useMapActiveUsers(map.uid, true, false, true);
return (
<div 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>
{activeUsers.length > 0 && (
<div className={styles.activeUsersMeta}>
<UsersIndicator users={activeUsers} limit={3} />
</div>
)}
</div>
</div>
<div className={styles.mapCardActions}>
<Button
variant="primary"
size="sm"
onClick={() => (window.location.href = `/atlas/${map.uid}`)}
>
Open
</Button>
<Button
variant="destructive"
fill="text"
size="sm"
icon="trash-alt"
onClick={onDelete}
aria-label="Delete map"
/>
</div>
</div>
);
}
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),
// eslint-disable-next-line @grafana/no-unreduced-motion
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),
}),
activeUsersMeta: css({
marginTop: theme.spacing(1),
display: 'flex',
alignItems: 'center',
}),
mapCardActions: css({
display: 'flex',
gap: theme.spacing(1),
justifyContent: 'flex-end',
}),
};
};

View File

@@ -0,0 +1,153 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
import { providePageContext, createAssistantContextItem } from '@grafana/assistant';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { ErrorBoundaryAlert, 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 { AddPanelAction, DebugAssistantContext } from './components/AssistantComponents';
import { ExploreMapCanvas } from './components/ExploreMapCanvas';
import { ExploreMapFloatingToolbar } from './components/ExploreMapFloatingToolbar';
import { ExploreMapToolbar } from './components/ExploreMapToolbar';
import { TransformProvider } from './context/TransformContext';
import { useCanvasPersistence } from './hooks/useCanvasPersistence';
import { useMapActiveUsers } from './hooks/useMapActiveUsers';
import { useRealtimeSync } from './realtime/useRealtimeSync';
// Register custom components for the Grafana Assistant using providePageContext
// This ensures the component context is properly sent to the assistant
providePageContext(/.*/, [
createAssistantContextItem('component', {
components: {
AddPanelAction,
},
namespace: 'exploreMap',
prompt: `You have access to an interactive component that helps users add panels to the Atlas canvas with pre-configured datasources and queries.
Component name: exploreMap_AddPanelAction
Whitelisted props:
- type: string - MUST ALWAYS BE "explore" (only explore panels are supported currently)
- description: string (optional custom button label)
- name: string (optional display name)
- namespace: string (optional datasource UID - use this to specify which datasource the panel should use)
- metric: string (optional query expression - PromQL for metrics, LogQL for logs, TraceQL for traces, etc.)
IMPORTANT: Query must be URL-encoded to handle special characters like parentheses, quotes, braces, etc.
Usage examples (place directly in response, NEVER in code blocks):
<exploreMap_AddPanelAction type="explore" />
<exploreMap_AddPanelAction type="explore" namespace="prometheus-uid" metric="up" />
<exploreMap_AddPanelAction type="explore" namespace="loki-uid" metric="%7Bjob%3D%22varlogs%22%7D" />
<exploreMap_AddPanelAction type="explore" namespace="prometheus-uid" metric="rate%28http_requests_total%5B5m%5D%29" description="HTTP Request Rate" />
<exploreMap_AddPanelAction type="explore" namespace="prometheus-uid" metric="histogram_quantile%280.95%2C%20sum%28rate%28http_request_duration_seconds_bucket%5B5m%5D%29%29%20by%20%28le%29%29" description="P95 Latency" />
CRITICAL RULES:
- Components must NEVER be wrapped in code blocks (no backticks or \`\`\`).
- **ALWAYS set type="explore"** - this is the only supported panel type currently.
- Use namespace prop to specify datasource UID when you know which datasource to use.
- Use metric prop to provide initial query expressions.
- **ALWAYS URL-encode the metric prop value** to handle special characters: ( ) [ ] { } " ' = < > etc.
Examples:
- '{job="varlogs"}' becomes '%7Bjob%3D%22varlogs%22%7D'
- 'rate(http_requests[5m])' becomes 'rate%28http_requests%5B5m%5D%29'
- Place components directly in your response text, not in code blocks.
- When users ask to add panels with specific queries, provide the component with namespace and URL-encoded metric props.
- You can provide multiple components in a single response for adding multiple panels.`,
}),
]);
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 (with uid if available)
const { loading } = useCanvasPersistence({ uid });
// Stable callback references for realtime sync
const handleConnected = useCallback(() => {
// Connected to real-time sync
}, []);
const handleDisconnected = useCallback(() => {
// Disconnected from real-time sync
}, []);
const handleError = useCallback((error: Error) => {
console.error('CRDT sync error:', error);
}, []);
// Enable real-time CRDT synchronization when uid is available
useRealtimeSync({
mapUid: uid || '',
enabled: !!uid,
onConnected: handleConnected,
onDisconnected: handleDisconnected,
onError: handleError,
});
// Track active users for this map and update Redux state
// This ensures users active in this map show in both toolbar and list view
useMapActiveUsers(uid, !!uid, true);
useEffect(() => {
chrome.update({
sectionNav: navModel,
});
}, [chrome, navModel]);
if (loading) {
return (
<div className={styles.loadingWrapper}>
<p>
<Trans i18nKey="explore-map.page.loading">Loading atlas...</Trans>
</p>
</div>
);
}
return (
<ErrorBoundaryAlert>
<TransformProvider value={{ transformRef }}>
<div className={styles.pageWrapper}>
<DebugAssistantContext />
<h1 className="sr-only">
<Trans i18nKey="nav.explore-map.title">Atlas</Trans>
</h1>
<ExploreMapToolbar uid={uid} />
<ExploreMapCanvas />
<ExploreMapFloatingToolbar />
</div>
</TransformProvider>
</ErrorBoundaryAlert>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
pageWrapper: css({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
backgroundColor: theme.colors.background.primary,
}),
loadingWrapper: css({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.colors.background.primary,
}),
};
};

View File

@@ -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/atlas';
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();

View File

@@ -0,0 +1,140 @@
import { css } from '@emotion/css';
import React, { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { useDispatch } from 'app/types/store';
import { addPanel } from '../../state/crdtSlice';
/**
* Whitelisted props that the Grafana Assistant allows for custom components.
* We creatively map these to our needs:
* - type: Panel type ('explore', 'metrics', 'logs', 'traces', 'profiles')
* - description: Custom button label (optional)
* - name: Display name (optional)
* - namespace: Datasource UID
* - metric: Query expression (PromQL, LogQL, etc.)
*/
interface AddPanelActionProps {
name?: string; // Display name
type?: string; // Panel type: 'explore' | 'metrics' | 'logs' | 'traces' | 'profiles'
description?: string; // Custom button label
properties?: string; // Reserved
env?: string; // Reserved
site?: string; // Reserved
namespace?: string; // Datasource UID
metric?: string; // Query expression
value?: string; // Reserved
unit?: string; // Reserved
status?: string; // Reserved
}
/**
* Custom component that the Grafana Assistant can use to add panels to the explore map canvas.
* The assistant can render this component in its responses to provide interactive "Add Panel" buttons.
*
* Uses whitelisted props only:
* - type: Panel type ('explore', 'metrics', 'logs', 'traces', 'profiles')
* - description: Custom button label (optional)
* - name: Display name (optional)
* - namespace: Datasource UID (optional)
* - metric: Query expression (optional)
*/
export const AddPanelAction: React.FC<AddPanelActionProps> = ({
type = 'explore',
description,
name,
namespace, // datasourceUid
metric, // query expression
}) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const currentUsername = contextSrv.user.name || contextSrv.user.login || 'Unknown';
// Map type to the internal mode format
const getMode = (): 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown' => {
switch (type?.toLowerCase()) {
case 'traces':
return 'traces-drilldown';
case 'metrics':
return 'metrics-drilldown';
case 'profiles':
return 'profiles-drilldown';
case 'logs':
return 'logs-drilldown';
default:
return 'explore';
}
};
const handleAddPanel = useCallback(() => {
// Decode the metric if it's URL-encoded
const decodedQuery = metric ? decodeURIComponent(metric) : undefined;
const mode = getMode();
dispatch(
addPanel({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
kind: mode,
createdBy: currentUsername,
datasourceUid: namespace, // Use namespace prop for datasource UID
query: decodedQuery, // Decode URL-encoded query expression
})
);
}, [dispatch, currentUsername, type, namespace, metric, getMode]);
const getButtonLabel = () => {
if (description) {
return description;
}
if (name) {
return name;
}
switch (getMode()) {
case 'traces-drilldown':
return 'Add Traces Panel';
case 'metrics-drilldown':
return 'Add Metrics Panel';
case 'profiles-drilldown':
return 'Add Profiles Panel';
case 'logs-drilldown':
return 'Add Logs Panel';
default:
return 'Add Explore Panel';
}
};
const getButtonIcon = () => {
switch (getMode()) {
case 'traces-drilldown':
case 'metrics-drilldown':
case 'profiles-drilldown':
case 'logs-drilldown':
return 'plus';
default:
return 'compass';
}
};
return (
<div className={styles.container}>
<Button icon={getButtonIcon()} onClick={handleAddPanel} variant="primary" size="sm">
{getButtonLabel()}
</Button>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
display: 'inline-block',
margin: theme.spacing(1, 0),
}),
};
};

View File

@@ -0,0 +1,26 @@
import React, { useEffect } from 'react';
import { usePageComponents, usePageContext } from '@grafana/assistant';
/**
* Debug component to verify assistant context and component registration.
* Add this temporarily to ExploreMapPage to see what's registered.
*/
export const DebugAssistantContext: React.FC = () => {
const pageComponents = usePageComponents();
const pageContext = usePageContext();
useEffect(() => {
// Debug logging disabled to reduce console noise
// Uncomment if needed for assistant debugging
// console.group('🔍 Assistant Debug Info');
// console.log('Current URL:', window.location.pathname);
// console.log('Registered Components:', Object.keys(pageComponents));
// console.log('Component Details:', pageComponents);
// console.log('Page Context Items:', pageContext.length);
// console.log('Page Context:', pageContext);
// console.groupEnd();
}, [pageComponents, pageContext]);
return null;
};

View File

@@ -0,0 +1,5 @@
// Re-export components (eslint exception for assistant components barrel file)
// eslint-disable-next-line no-barrel-files/no-barrel-files
export { AddPanelAction } from './AddPanelAction';
// eslint-disable-next-line no-barrel-files/no-barrel-files
export { DebugAssistantContext } from './DebugAssistantContext';

View File

@@ -0,0 +1,71 @@
import { useCallback, useState } from 'react';
import { Checkbox, ConfirmModal } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { splitClose } from '../../explore/state/main';
import { removeFrame } from '../state/crdtSlice';
import { selectPanelsInFrame, selectPanels } from '../state/selectors';
interface ConfirmDeleteFrameDialogProps {
frameId: string;
frameTitle: string;
panelCount: number;
onClose: () => void;
}
export function ConfirmDeleteFrameDialog({ frameId, frameTitle, panelCount, onClose }: ConfirmDeleteFrameDialogProps) {
const dispatch = useDispatch();
const panelsInFrame = useSelector((state) => selectPanelsInFrame(state.exploreMapCRDT, frameId));
const allPanels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const [deletePanels, setDeletePanels] = useState(false);
const handleConfirm = useCallback(() => {
// If deleting panels, clean up Explore state first
if (deletePanels) {
for (const panelId of panelsInFrame) {
const panel = allPanels[panelId];
if (panel) {
dispatch(splitClose(panel.exploreId));
}
}
}
dispatch(
removeFrame({
frameId,
deletePanels,
})
);
onClose();
}, [dispatch, frameId, deletePanels, onClose, panelsInFrame, allPanels]);
return (
<ConfirmModal
isOpen={true}
title="Delete frame"
body={
<div>
<p>
Are you sure you want to delete the frame &quot;{frameTitle}&quot;?
</p>
{panelCount > 0 && (
<>
<p>
This frame contains {panelCount} panel{panelCount > 1 ? 's' : ''}.
</p>
<Checkbox
label="Also delete all panels in this frame"
value={deletePanels}
onChange={(e) => setDeletePanels(e.currentTarget.checked)}
/>
</>
)}
</div>
}
confirmText="Delete"
onConfirm={handleConfirm}
onDismiss={onClose}
/>
);
}

View File

@@ -0,0 +1,274 @@
import { css } from '@emotion/css';
import { useEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { updatePanelIframeUrl } from '../../state/crdtSlice';
import { selectPanels } from '../../state/selectors';
interface ExploreMapDrilldownPanelProps {
exploreId: string;
width: number;
height: number;
mode: 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown';
appPath: string;
titleKey: string;
titleDefault: string;
}
export function ExploreMapDrilldownPanel({
exploreId,
width,
height,
mode,
appPath,
titleKey,
titleDefault,
}: ExploreMapDrilldownPanelProps) {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const [isInitialized, setIsInitialized] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Find the panel with this exploreId to get saved state
const panel = useSelector((state) => {
const panels = selectPanels(state.exploreMapCRDT);
return Object.values(panels).find((p) => p.exploreId === exploreId);
});
// Initialize URL from panel state or default
// Add kiosk parameter to hide chrome (including breadcrumbs) in the iframe
const initialUrl = (() => {
const origin = window.location.origin;
const subUrl = config.appSubUrl || '';
const baseUrl = `${origin}${subUrl}${appPath}`;
// Add kiosk parameter to hide chrome/breadcrumbs (kiosk=1 enables full kiosk mode)
const url = new URL(baseUrl, window.location.href);
url.searchParams.set('kiosk', '1');
return url.toString();
})();
const [drilldownUrl, setDrilldownUrl] = useState(initialUrl);
const lastLocalUrlRef = useRef<string | undefined>(undefined);
const lastRemoteUrlRef = useRef<string | undefined>(undefined);
const isInitialMountRef = useRef(true);
const hasSetInitialUrlRef = useRef(false);
const lastPanelIframeUrlRef = useRef<string | undefined>(undefined);
const lastAppliedRemoteUrlRef = useRef<string | undefined>(undefined);
const remoteUrlAppliedTimeRef = useRef<number>(0);
// Initialize immediately for drilldown panels
useEffect(() => {
setIsInitialized(true);
}, []);
// Update iframe URL when panel becomes available or URL changes
useEffect(() => {
if (!isInitialized) {
return;
}
// Wait for panel to be available
if (!panel) {
return;
}
// Get the current URL from panel state
const panelIframeUrl = panel.mode === mode ? panel.iframeUrl : undefined;
// Skip if panel iframeUrl hasn't actually changed (prevents unnecessary refreshes)
if (panelIframeUrl === lastPanelIframeUrlRef.current && !isInitialMountRef.current) {
return;
}
lastPanelIframeUrlRef.current = panelIframeUrl;
let currentUrl = panelIframeUrl || initialUrl;
// Ensure kiosk parameter is present to hide breadcrumbs
try {
const url = new URL(currentUrl, window.location.href);
if (!url.searchParams.has('kiosk')) {
url.searchParams.set('kiosk', '1');
currentUrl = url.toString();
}
} catch (e) {
// If URL parsing fails, use the original URL
}
// On initial mount, set the URL immediately (only once)
if (isInitialMountRef.current) {
isInitialMountRef.current = false;
if (!hasSetInitialUrlRef.current) {
hasSetInitialUrlRef.current = true;
// Only update if URL is different from initial
if (currentUrl !== initialUrl) {
setDrilldownUrl(currentUrl);
}
}
return;
}
// Skip if this is the same URL we already have
if (currentUrl === drilldownUrl) {
return;
}
// Skip if this is a local update we just made
if (currentUrl === lastLocalUrlRef.current) {
return;
}
// Skip if we already processed this remote URL
if (currentUrl === lastRemoteUrlRef.current) {
return;
}
// This is a new remote URL - update the iframe
lastRemoteUrlRef.current = currentUrl;
lastAppliedRemoteUrlRef.current = currentUrl;
remoteUrlAppliedTimeRef.current = Date.now();
setDrilldownUrl(currentUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panel?.iframeUrl, isInitialized]);
// Auto-save iframe URL changes for drilldown panels
useEffect(() => {
if (!isInitialized) {
return;
}
const panelId = panel?.id;
if (!panelId) {
return;
}
let lastSavedUrl = panel?.iframeUrl || '';
const checkAndSaveUrl = () => {
const iframe = iframeRef.current;
if (!iframe) {
return;
}
try {
const currentUrl = iframe.contentWindow?.location.href;
if (currentUrl && currentUrl !== lastSavedUrl) {
// Skip if this URL was just applied from a remote update (within last 3 seconds)
// This prevents the refresh loop where remote updates trigger saves that sync back
const timeSinceRemoteUpdate = Date.now() - remoteUrlAppliedTimeRef.current;
if (
lastAppliedRemoteUrlRef.current &&
currentUrl === lastAppliedRemoteUrlRef.current &&
timeSinceRemoteUpdate < 3000
) {
// This is the URL we just applied from remote - don't save it back
lastSavedUrl = currentUrl;
return;
}
// Ensure kiosk parameter is present in the saved URL
let urlToSave = currentUrl;
try {
const url = new URL(currentUrl, window.location.href);
if (!url.searchParams.has('kiosk')) {
url.searchParams.set('kiosk', '1');
urlToSave = url.toString();
}
} catch (e) {
// If URL parsing fails, use the original URL
}
lastSavedUrl = urlToSave;
// Mark this as a local update so we don't reload the iframe when the CRDT syncs back
lastLocalUrlRef.current = urlToSave;
// Clear the remote URL marker since we're updating locally
lastRemoteUrlRef.current = undefined;
lastAppliedRemoteUrlRef.current = undefined;
dispatch(
updatePanelIframeUrl({
panelId,
iframeUrl: urlToSave,
})
);
// Clear the local URL marker after a delay to allow for remote updates
setTimeout(() => {
// Only clear if it's still the same URL (no new local navigation happened)
if (lastLocalUrlRef.current === urlToSave) {
lastLocalUrlRef.current = undefined;
}
}, 2000);
}
} catch (e) {
console.error('Cannot read iframe URL:', e);
}
};
// Start checking periodically (every 2 seconds)
const intervalId = setInterval(checkAndSaveUrl, 2000);
// Also check immediately
setTimeout(checkAndSaveUrl, 100);
return () => {
clearInterval(intervalId);
};
// Intentionally not including panel.iframeUrl to avoid re-creating interval on every save
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isInitialized, panel?.id, dispatch]);
if (!isInitialized) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Trans i18nKey="explore-map.panel.initializing">Initializing...</Trans>
</div>
</div>
);
}
return (
<div
className={styles.container}
style={{
width: `${width}px`,
height: `${height}px`,
}}
>
<iframe
ref={iframeRef}
src={drilldownUrl}
title={t(titleKey, titleDefault)}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
/>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative',
}),
loading: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: theme.colors.text.secondary,
fontSize: theme.typography.body.fontSize,
}),
};
};

View File

@@ -0,0 +1,22 @@
import { ExploreMapDrilldownPanel } from './ExploreMapDrilldownPanel';
interface ExploreMapLogsDrilldownPanelProps {
exploreId: string;
width: number;
height: number;
}
export function ExploreMapLogsDrilldownPanel({ exploreId, width, height }: ExploreMapLogsDrilldownPanelProps) {
return (
<ExploreMapDrilldownPanel
exploreId={exploreId}
width={width}
height={height}
mode="logs-drilldown"
appPath="/a/grafana-lokiexplore-app"
titleKey="explore-map.panel.logs-drilldown"
titleDefault="Logs Drilldown"
/>
);
}

View File

@@ -0,0 +1,22 @@
import { ExploreMapDrilldownPanel } from './ExploreMapDrilldownPanel';
interface ExploreMapMetricsDrilldownPanelProps {
exploreId: string;
width: number;
height: number;
}
export function ExploreMapMetricsDrilldownPanel({ exploreId, width, height }: ExploreMapMetricsDrilldownPanelProps) {
return (
<ExploreMapDrilldownPanel
exploreId={exploreId}
width={width}
height={height}
mode="metrics-drilldown"
appPath="/a/grafana-metricsdrilldown-app"
titleKey="explore-map.panel.metrics-drilldown"
titleDefault="Metrics Drilldown"
/>
);
}

View File

@@ -0,0 +1,22 @@
import { ExploreMapDrilldownPanel } from './ExploreMapDrilldownPanel';
interface ExploreMapProfilesDrilldownPanelProps {
exploreId: string;
width: number;
height: number;
}
export function ExploreMapProfilesDrilldownPanel({ exploreId, width, height }: ExploreMapProfilesDrilldownPanelProps) {
return (
<ExploreMapDrilldownPanel
exploreId={exploreId}
width={width}
height={height}
mode="profiles-drilldown"
appPath="/a/grafana-pyroscope-app"
titleKey="explore-map.panel.profiles-drilldown"
titleDefault="Profiles Drilldown"
/>
);
}

View File

@@ -0,0 +1,22 @@
import { ExploreMapDrilldownPanel } from './ExploreMapDrilldownPanel';
interface ExploreMapTracesDrilldownPanelProps {
exploreId: string;
width: number;
height: number;
}
export function ExploreMapTracesDrilldownPanel({ exploreId, width, height }: ExploreMapTracesDrilldownPanelProps) {
return (
<ExploreMapDrilldownPanel
exploreId={exploreId}
width={width}
height={height}
mode="traces-drilldown"
appPath="/a/grafana-exploretraces-app"
titleKey="explore-map.panel.traces-drilldown"
titleDefault="Traces Drilldown"
/>
);
}

View File

@@ -0,0 +1,128 @@
/**
* Component to display cursor indicators at the edge of the viewport
* for cursors that are currently off-screen
*/
import { css, keyframes } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { CursorViewportInfo } from '../hooks/useCursorViewportTracking';
interface EdgeCursorIndicatorProps {
cursorInfo: CursorViewportInfo;
}
export function EdgeCursorIndicator({ cursorInfo }: EdgeCursorIndicatorProps) {
const styles = useStyles2(getStyles);
if (cursorInfo.isVisible || !cursorInfo.edgePosition) {
return null;
}
const { cursor, edgePosition } = cursorInfo;
const { x, y, side } = edgePosition;
// Calculate rotation based on side
let rotation = 0;
switch (side) {
case 'top':
rotation = -90;
break;
case 'bottom':
rotation = 90;
break;
case 'left':
rotation = 180;
break;
case 'right':
rotation = 0;
break;
}
return (
<div
className={styles.container}
style={{
left: `${x}px`,
top: `${y}px`,
}}
>
<div className={styles.indicator} style={{ backgroundColor: cursor.color }}>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ transform: `rotate(${rotation}deg)` }}
>
<path d="M6 4L10 8L6 12" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className={styles.label} style={{ backgroundColor: cursor.color }}>
{cursor.userName}
</div>
</div>
);
}
const pulseAnimation = keyframes`
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
`;
const fadeInAnimation = keyframes`
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
`;
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
position: 'absolute',
pointerEvents: 'none',
zIndex: 10001, // Above regular cursors
transform: 'translate(-50%, -50%)',
transition: 'left 0.2s ease-out, top 0.2s ease-out, opacity 0.2s ease-out',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
animation: `${fadeInAnimation} 0.2s ease-out`,
}),
indicator: css({
width: '32px',
height: '32px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: theme.shadows.z3,
border: `2px solid white`,
animation: `${pulseAnimation} 2s ease-in-out infinite`,
}),
label: css({
padding: '2px 6px',
borderRadius: theme.shape.radius.default,
color: 'white',
fontSize: '11px',
fontWeight: theme.typography.fontWeightMedium,
whiteSpace: 'nowrap',
boxShadow: theme.shadows.z2,
}),
};
};

View File

@@ -0,0 +1,436 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { useTransformContext } from '../context/TransformContext';
import { useCursorSync } from '../hooks/useCursorSync';
import { useCursorViewportTracking } from '../hooks/useCursorViewportTracking';
import { selectPanel as selectPanelCRDT, updateViewport as updateViewportCRDT, selectMultiplePanels as selectMultiplePanelsCRDT } from '../state/crdtSlice';
import { selectPanels, selectFrames, selectViewport, selectCursors, selectSelectedPanelIds, selectMapUid, selectPostItNotes, selectCursorMode } from '../state/selectors';
import { EdgeCursorIndicator } from './EdgeCursorIndicator';
import { ExploreMapComment } from './ExploreMapComment';
import { ExploreMapFrame } from './ExploreMapFrame';
import { ExploreMapPanelContainer } from './ExploreMapPanelContainer';
import { ExploreMapStickyNote } from './ExploreMapStickyNote';
import { Minimap } from './Minimap';
import { UserCursor } from './UserCursor';
interface SelectionRect {
startX: number;
startY: number;
currentX: number;
currentY: number;
}
export function ExploreMapCanvas() {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const canvasRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { transformRef: contextTransformRef } = useTransformContext();
const [selectionRect, setSelectionRect] = useState<SelectionRect | null>(null);
const [isSelecting, setIsSelecting] = useState(false);
const justCompletedSelectionRef = useRef(false);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const frames = useSelector((state) => selectFrames(state.exploreMapCRDT));
const postItNotes = useSelector((state) => selectPostItNotes(state.exploreMapCRDT));
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const cursors = useSelector((state) => selectCursors(state.exploreMapCRDT));
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
const mapUid = useSelector((state) => selectMapUid(state.exploreMapCRDT));
const cursorMode = useSelector((state) => selectCursorMode(state.exploreMapCRDT));
// Initialize cursor sync
const { updatePosition, updatePositionImmediate } = useCursorSync({
mapUid: mapUid || '',
enabled: !!mapUid,
});
// Track cursor viewport positions for edge indicators
const cursorViewportInfo = useCursorViewportTracking({
cursors,
viewport,
transformRef: contextTransformRef,
containerWidth: containerSize.width,
containerHeight: containerSize.height,
});
// Track container size for viewport calculations
// Since the canvas is 50000px and all parent containers are expanding to fit it,
// we need to find the actual viewport-constrained container
useEffect(() => {
const updateSize = () => {
// Walk up the DOM to find the first element that has a constrained height
let element = containerRef.current?.parentElement;
let width = 0;
let height = 0;
while (element) {
const elementHeight = element.clientHeight;
const elementWidth = element.clientWidth;
// Look for an element with a constrained height (not 50000px)
if (elementHeight < 50000 && elementHeight > 0) {
width = elementWidth;
height = elementHeight;
break;
}
element = element.parentElement;
}
if (width > 0 && height > 0) {
setContainerSize({ width, height });
}
};
// Delay initial size update to ensure DOM is mounted
const timeoutId = setTimeout(updateSize, 100);
// Update on resize
const handleResize = () => {
updateSize();
};
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', handleResize);
};
}, []);
// Send selection update immediately when selection changes
// Use position (0, 0) since we only care about the selection, not cursor position
useEffect(() => {
updatePositionImmediate(0, 0, selectedPanelIds);
}, [selectedPanelIds, updatePositionImmediate]);
const handleCanvasClick = useCallback(
(e: React.MouseEvent) => {
// Don't deselect if we just finished a selection drag
if (justCompletedSelectionRef.current) {
justCompletedSelectionRef.current = false;
return;
}
// Only deselect if clicking directly on canvas (not on panels) and there are panels to deselect
if (e.target === e.currentTarget && selectedPanelIds.length > 0) {
dispatch(selectPanelCRDT({ panelId: undefined }));
}
},
[dispatch, selectedPanelIds]
);
const handleCanvasMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Don't allow selection in hand mode (panning mode)
if (cursorMode === 'hand') {
return;
}
// Only start selection if clicking directly on canvas (not on panels)
if (e.target !== e.currentTarget) {
return;
}
// Don't start selection on middle or right click
if (e.button !== 0) {
return;
}
if (!canvasRef.current) {
return;
}
const canvasRect = canvasRef.current.getBoundingClientRect();
const screenX = e.clientX - canvasRect.left;
const screenY = e.clientY - canvasRect.top;
// Convert screen coordinates to actual canvas coordinates
// Use transform ref state if available, otherwise fall back to viewport state
const scale = contextTransformRef?.current?.state?.scale ?? viewport.zoom;
// Since getBoundingClientRect() gives us the rect AFTER transform,
// we just need to divide by scale to get canvas coordinates
const canvasX = screenX / scale;
const canvasY = screenY / scale;
setSelectionRect({
startX: canvasX,
startY: canvasY,
currentX: canvasX,
currentY: canvasY,
});
setIsSelecting(true);
},
[contextTransformRef, viewport, cursorMode]
);
const handleCanvasMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Get position relative to the canvas element, not the event target
if (!canvasRef.current) {
return;
}
// Get the bounding rect of the transformed canvas element
const canvasRect = canvasRef.current.getBoundingClientRect();
// Calculate position relative to the canvas element's top-left corner (in screen space)
const screenX = e.clientX - canvasRect.left;
const screenY = e.clientY - canvasRect.top;
// The canvas element is inside TransformComponent which applies CSS transform: scale() and translate()
// The bounding rect already reflects the transform, so screenX/screenY are in the zoomed coordinate space
// We need to convert back to the original 50000x50000 canvas coordinate space
const scale = contextTransformRef?.current?.state?.scale ?? viewport.zoom;
// Since getBoundingClientRect() gives us the rect AFTER transform,
// we just need to divide by scale to get canvas coordinates
const canvasX = Math.max(0, Math.min(50000, screenX / scale));
const canvasY = Math.max(0, Math.min(50000, screenY / scale));
// Update cursor position for all sessions (using actual canvas coordinates, clamped to bounds)
// Include currently selected panel IDs for collaborative selection visualization
updatePosition(canvasX, canvasY, selectedPanelIds);
// Handle selection rectangle if dragging (using canvas coordinates)
if (isSelecting && selectionRect) {
setSelectionRect({
...selectionRect,
currentX: canvasX,
currentY: canvasY,
});
}
},
[isSelecting, selectionRect, updatePosition, contextTransformRef, viewport, selectedPanelIds]
);
const handleCanvasMouseUp = useCallback(
(e: React.MouseEvent) => {
if (!isSelecting || !selectionRect) {
return;
}
// Calculate selection rectangle bounds
const minX = Math.min(selectionRect.startX, selectionRect.currentX);
const maxX = Math.max(selectionRect.startX, selectionRect.currentX);
const minY = Math.min(selectionRect.startY, selectionRect.currentY);
const maxY = Math.max(selectionRect.startY, selectionRect.currentY);
// Find panels that intersect with selection rectangle
const selectedPanelIds = Object.values(panels).filter((panel) => {
const panelLeft = panel.position.x;
const panelRight = panel.position.x + panel.position.width;
const panelTop = panel.position.y;
const panelBottom = panel.position.y + panel.position.height;
const intersects = !(panelRight < minX || panelLeft > maxX || panelBottom < minY || panelTop > maxY);
return intersects;
}).map((panel) => panel.id);
// Check if Cmd/Ctrl is held for additive selection
const isAdditive = e.metaKey || e.ctrlKey;
if (selectedPanelIds.length > 0) {
// Select all panels at once
dispatch(selectMultiplePanelsCRDT({ panelIds: selectedPanelIds, addToSelection: isAdditive }));
justCompletedSelectionRef.current = true;
} else if (!isAdditive) {
// Clear selection if no panels selected and not holding modifier
dispatch(selectPanelCRDT({ panelId: undefined }));
}
setSelectionRect(null);
setIsSelecting(false);
},
[isSelecting, selectionRect, panels, dispatch]
);
const handleTransformChange = useCallback(
(ref: ReactZoomPanPinchRef) => {
dispatch(
updateViewportCRDT({
zoom: ref.state.scale,
panX: ref.state.positionX,
panY: ref.state.positionY,
})
);
// Update grid scale dynamically to maintain visibility at different zoom levels
if (canvasRef.current) {
const scale = ref.state.scale;
// Adjust grid size and dot size based on zoom level to maintain visibility
let gridSize = 20;
let dotSize = 1.5;
if (scale < 0.8) {
gridSize = 40;
dotSize = 3;
}
if (scale < 0.5) {
gridSize = 80;
dotSize = 4;
}
if (scale < 0.3) {
gridSize = 160;
dotSize = 6;
}
if (scale < 0.2) {
gridSize = 200;
dotSize = 10;
}
if (scale < 0.15) {
gridSize = 320;
dotSize = 14;
}
if (scale < 0.1) {
gridSize = 640;
dotSize = 18;
}
canvasRef.current.style.backgroundSize = `${gridSize}px ${gridSize}px`;
canvasRef.current.style.backgroundImage = `
radial-gradient(circle, var(--grid-color) ${dotSize}px, transparent ${dotSize}px)
`;
}
},
[dispatch]
);
return (
<div ref={containerRef} className={styles.canvasWrapper} onMouseMove={handleCanvasMouseMove}>
<TransformWrapper
ref={contextTransformRef}
initialScale={viewport.zoom}
initialPositionX={viewport.panX}
initialPositionY={viewport.panY}
minScale={0.1}
maxScale={4}
limitToBounds={false}
centerOnInit={false}
panning={{
disabled: false,
excluded: ['panel-drag-handle', 'react-rnd'],
allowLeftClickPan: cursorMode === 'hand',
allowRightClickPan: false,
allowMiddleClickPan: true,
}}
onTransformed={handleTransformChange}
doubleClick={{ disabled: true }}
wheel={{ step: 0.1 }}
>
<TransformComponent
wrapperClass={`${styles.transformWrapper} ${cursorMode === 'hand' ? styles.handCursor : styles.pointerCursor}`}
contentClass={styles.transformContent}
>
<div
ref={canvasRef}
className={styles.canvas}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
onMouseUp={handleCanvasMouseUp}
onKeyDown={(e) => {
if (e.key === 'Escape') {
dispatch(selectPanelCRDT({ panelId: undefined }));
}
}}
role="button"
tabIndex={0}
>
{/* Render frames first (lower z-index) */}
{Object.values(frames).map((frame) => (
<ExploreMapFrame key={frame.id} frame={frame} />
))}
{/* Render panels on top */}
{Object.values(panels).map((panel) => {
return <ExploreMapPanelContainer key={panel.id} panel={panel} />;
})}
{cursorViewportInfo
.filter((info) => info.isVisible)
.map((info) => (
<UserCursor key={info.cursor.sessionId} cursor={info.cursor} zoom={viewport.zoom} />
))}
{Object.values(postItNotes).map((postIt) => (
<ExploreMapStickyNote key={postIt.id} postIt={postIt} zoom={viewport.zoom} />
))}
{selectionRect && (
<div
className={styles.selectionRect}
style={{
left: `${Math.min(selectionRect.startX, selectionRect.currentX)}px`,
top: `${Math.min(selectionRect.startY, selectionRect.currentY)}px`,
width: `${Math.abs(selectionRect.currentX - selectionRect.startX)}px`,
height: `${Math.abs(selectionRect.currentY - selectionRect.startY)}px`,
}}
/>
)}
</div>
</TransformComponent>
</TransformWrapper>
{/* Render edge indicators for off-screen cursors */}
{cursorViewportInfo
.filter((info) => !info.isVisible)
.map((info) => (
<EdgeCursorIndicator key={info.cursor.sessionId} cursorInfo={info} />
))}
<ExploreMapComment />
<Minimap containerWidth={containerSize.width} containerHeight={containerSize.height} />
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
canvasWrapper: css({
position: 'relative',
flex: 1,
overflow: 'hidden',
backgroundColor: theme.colors.background.canvas,
height: '100%', // Ensure it doesn't expand beyond flex allocation
minHeight: 0, // Allow flex shrinking
}),
transformWrapper: css({
width: '100%',
height: '100%',
}),
pointerCursor: css({
cursor: 'default',
}),
handCursor: css({
cursor: 'grab',
'&:active': {
cursor: 'grabbing',
},
}),
transformContent: css({
width: '100%',
height: '100%',
}),
canvas: css({
position: 'relative',
width: '50000px',
height: '50000px',
'--grid-color': theme.colors.border.weak,
backgroundImage: `
radial-gradient(circle, var(--grid-color) 1.5px, transparent 1.5px)
`,
backgroundSize: '20px 20px',
}),
selectionRect: css({
position: 'absolute',
border: `2px solid ${theme.colors.primary.border}`,
backgroundColor: theme.colors.primary.transparent,
pointerEvents: 'none',
zIndex: 9999,
}),
};
};

View File

@@ -0,0 +1,408 @@
import { css, cx } from '@emotion/css';
import { useCallback, useEffect, useRef, useState } from 'react';
import { dateTime, GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Button, ConfirmModal, Icon, TextArea, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { useDispatch, useSelector } from 'app/types/store';
import { CommentData } from '../crdt/types';
import { addComment, removeComment } from '../state/crdtSlice';
import { selectComments } from '../state/selectors';
export function ExploreMapComment() {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const comments = useSelector((state) => selectComments(state.exploreMapCRDT));
const [editing, setEditing] = useState(false);
const [commentValue, setCommentValue] = useState('');
const [commentToDelete, setCommentToDelete] = useState<string | null>(null);
const [isCollapsed, setIsCollapsed] = useState(true);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const commentsEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (editing && textAreaRef.current) {
textAreaRef.current.focus();
}
}, [editing]);
// Auto-scroll to bottom when new comments are added
useEffect(() => {
if (commentsEndRef.current) {
commentsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [comments.length]);
const handleAddCommentClick = useCallback(() => {
setEditing(true);
setIsCollapsed(false); // Expand when adding a comment
}, []);
const handleHeaderClick = useCallback(() => {
setIsCollapsed((prev) => !prev);
}, []);
const handleHeaderKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleHeaderClick();
}
},
[handleHeaderClick]
);
const handleSave = useCallback(() => {
const trimmedText = commentValue.trim();
if (trimmedText) {
const commentData: CommentData = {
text: trimmedText,
username: contextSrv.user.name || contextSrv.user.login || 'Unknown',
timestamp: Date.now(),
};
dispatch(addComment({ comment: commentData }));
setCommentValue('');
}
setEditing(false);
}, [dispatch, commentValue]);
const handleCancel = useCallback(() => {
setCommentValue('');
setEditing(false);
}, []);
const handleCommentKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel();
}
// Allow Ctrl/Cmd+Enter to save
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleSave();
}
},
[handleCancel, handleSave]
);
const formatTimestamp = useCallback((timestamp: number) => {
if (!timestamp) {return '';}
const dt = dateTime(timestamp);
const now = dateTime();
const diff = now.diff(dt, 'minutes');
if (diff < 1) {
return t('explore-map.comment.just-now', 'Just now');
} else if (diff < 60) {
return t('explore-map.comment.minutes-ago', '{{minutes}}m ago', { minutes: Math.floor(diff) });
} else if (diff < 1440) {
return t('explore-map.comment.hours-ago', '{{hours}}h ago', { hours: Math.floor(diff / 60) });
} else {
return dt.format('MMM D, YYYY HH:mm');
}
}, []);
const handleRemoveCommentClick = useCallback(
(commentId: string, e: React.MouseEvent) => {
e.stopPropagation();
setCommentToDelete(commentId);
},
[]
);
const handleConfirmDelete = useCallback(() => {
if (commentToDelete) {
dispatch(removeComment({ commentId: commentToDelete }));
setCommentToDelete(null);
}
}, [dispatch, commentToDelete]);
const handleCancelDelete = useCallback(() => {
setCommentToDelete(null);
}, []);
const currentUsername = contextSrv.user.name || contextSrv.user.login || 'Unknown';
if (editing) {
return (
<div className={styles.commentContainer}>
<div className={styles.commentEditor}>
<TextArea
ref={textAreaRef}
value={commentValue}
onChange={(e) => setCommentValue(e.currentTarget.value)}
onKeyDown={handleCommentKeyDown}
placeholder={t('explore-map.comment.placeholder', 'Add a comment...')}
rows={3}
className={styles.commentTextArea}
/>
<div className={styles.editorActions}>
<Button
variant="secondary"
size="sm"
onClick={handleCancel}
icon="times"
>
{t('explore-map.comment.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
icon="check"
>
{t('explore-map.comment.save', 'Save')}
</Button>
</div>
<div className={styles.commentHint}>
{t('explore-map.comment.hint', 'Press Ctrl+Enter to save, Esc to cancel')}
</div>
</div>
</div>
);
}
return (
<div className={styles.commentContainer}>
<div
className={cx(styles.commentsHeader, isCollapsed && styles.commentsHeaderCollapsed)}
onClick={handleHeaderClick}
onKeyDown={handleHeaderKeyDown}
role="button"
tabIndex={0}
>
<Icon name="comment-alt" className={styles.headerIcon} />
<span className={styles.headerText}>
{t('explore-map.comment.comments', 'Comments')} ({comments.length})
</span>
<Button
variant="primary"
size="sm"
icon="plus"
onClick={(e) => {
e.stopPropagation();
handleAddCommentClick();
}}
className={styles.addButton}
>
{t('explore-map.comment.add', 'Add')}
</Button>
</div>
{!isCollapsed && (
<div className={styles.commentsList}>
{comments.length === 0 ? (
<div className={styles.emptyState}>
<Icon name="comment-alt" className={styles.emptyIcon} />
<span className={styles.emptyText}>
{t('explore-map.comment.no-comments', 'No comments yet. Be the first to comment!')}
</span>
</div>
) : (
comments.map(({ id, data }) => (
<div key={id} className={styles.commentItem}>
<div className={styles.commentContent}>
<span className={styles.commentText}>{data.text}</span>
<div className={styles.commentMeta}>
<span className={styles.commentUsername}>{data.username}</span>
{data.timestamp && (
<span className={styles.commentTimestamp}>
{formatTimestamp(data.timestamp)}
</span>
)}
</div>
</div>
<div className={styles.commentActions}>
{data.username === currentUsername && (
<Button
variant="secondary"
size="sm"
fill="text"
icon="trash-alt"
onClick={(e) => handleRemoveCommentClick(id, e)}
className={styles.deleteButton}
tooltip={t('explore-map.comment.delete', 'Delete comment')}
/>
)}
</div>
</div>
))
)}
<div ref={commentsEndRef} />
</div>
)}
<ConfirmModal
isOpen={commentToDelete !== null}
title={t('explore-map.comment.delete-title', 'Delete comment')}
body={t('explore-map.comment.delete-body', 'Are you sure you want to delete this comment? This action cannot be undone.')}
confirmText={t('explore-map.comment.delete-confirm', 'Delete')}
dismissText={t('explore-map.comment.delete-cancel', 'Cancel')}
onConfirm={handleConfirmDelete}
onDismiss={handleCancelDelete}
icon="exclamation-triangle"
confirmButtonVariant="destructive"
/>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
commentContainer: css({
position: 'fixed',
bottom: theme.spacing(3),
right: theme.spacing(3),
width: '300px',
maxHeight: '500px',
zIndex: 1001,
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
}),
commentsHeader: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(1.5),
borderBottom: `1px solid ${theme.colors.border.weak}`,
cursor: 'pointer',
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'background-color 0.2s',
},
'&:hover': {
backgroundColor: theme.colors.background.primary,
},
}),
commentsHeaderCollapsed: css({
borderBottom: 'none',
}),
headerIcon: css({
color: theme.colors.text.secondary,
}),
headerText: css({
flex: 1,
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
}),
addButton: css({
flexShrink: 0,
}),
commentsList: css({
flex: 1,
overflowY: 'auto',
maxHeight: '400px',
padding: theme.spacing(1),
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
emptyState: css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(3),
textAlign: 'center',
color: theme.colors.text.secondary,
}),
emptyIcon: css({
fontSize: theme.spacing(4),
marginBottom: theme.spacing(1),
opacity: 0.5,
}),
emptyText: css({
fontSize: theme.typography.bodySmall.fontSize,
fontStyle: 'italic',
}),
commentItem: css({
display: 'flex',
alignItems: 'flex-start',
gap: theme.spacing(1),
padding: theme.spacing(1.5),
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'background-color 0.2s',
},
'&:hover': {
backgroundColor: theme.colors.background.canvas,
},
}),
commentContent: css({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
}),
commentText: css({
fontSize: theme.typography.body.fontSize,
color: theme.colors.text.primary,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
lineHeight: theme.typography.body.lineHeight,
}),
commentMeta: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
fontSize: theme.typography.bodySmall.fontSize,
marginTop: theme.spacing(0.5),
}),
commentUsername: css({
color: theme.colors.text.secondary,
fontWeight: theme.typography.fontWeightMedium,
}),
commentTimestamp: css({
color: theme.colors.text.disabled,
fontSize: theme.typography.bodySmall.fontSize,
}),
commentActions: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
flexShrink: 0,
}),
deleteButton: css({
opacity: 0.6,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 0.2s',
},
'&:hover': {
opacity: 1,
color: theme.colors.error.text,
},
}),
commentEditor: css({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(1.5),
}),
commentTextArea: css({
width: '100%',
resize: 'vertical',
minHeight: '60px',
marginBottom: theme.spacing(1),
}),
editorActions: css({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(1),
marginTop: theme.spacing(1),
}),
commentHint: css({
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
fontStyle: 'italic',
marginTop: theme.spacing(0.5),
textAlign: 'right',
}),
};
};

View File

@@ -0,0 +1,559 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAssistant, createAssistantContextItem } from '@grafana/assistant';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Button, ButtonGroup, Dropdown, Menu, MenuItem, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import pyroscopeIconSvg from 'app/plugins/datasource/grafana-pyroscope-datasource/img/grafana_pyroscope_icon.svg';
import lokiIconSvg from 'app/plugins/datasource/loki/img/loki_icon.svg';
import prometheusLogoSvg from 'app/plugins/datasource/prometheus/img/prometheus_logo.svg';
import tempoLogoSvg from 'app/plugins/datasource/tempo/img/tempo_logo.svg';
import { useDispatch, useSelector } from 'app/types/store';
import { addPanel, addFrame, addPostItNote, pastePanel, setCursorMode } from '../state/crdtSlice';
import {
selectPanels,
selectMapUid,
selectViewport,
selectSelectedPanelIds,
selectCursorMode,
selectClipboard,
} from '../state/selectors';
import { AddPanelAction } from './AssistantComponents';
export function ExploreMapFloatingToolbar() {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const [isOpen, setIsOpen] = useState(false);
const [hasSystemClipboard, setHasSystemClipboard] = useState(false);
const currentUsername = contextSrv.user.name || contextSrv.user.login || 'Unknown';
// Get assistant functionality
const { isAvailable: isAssistantAvailable, openAssistant } = useAssistant();
// Get canvas state for assistant context
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const mapUid = useSelector((state) => selectMapUid(state.exploreMapCRDT));
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
const cursorMode = useSelector((state) => selectCursorMode(state.exploreMapCRDT));
const clipboard = useSelector((state) => selectClipboard(state.exploreMapCRDT));
// Check system clipboard for panel data when dropdown opens
useEffect(() => {
if (!isOpen) {
return;
}
const checkClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
const data = JSON.parse(text);
if (data && typeof data === 'object' && 'mode' in data && 'width' in data && 'height' in data) {
setHasSystemClipboard(true);
} else {
setHasSystemClipboard(false);
}
} catch {
setHasSystemClipboard(false);
}
};
checkClipboard();
}, [isOpen]);
const handleAddPanel = useCallback(() => {
dispatch(
addPanel({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
createdBy: currentUsername,
})
);
setIsOpen(false);
}, [dispatch, currentUsername]);
const handleAddTracesDrilldownPanel = useCallback(() => {
dispatch(
addPanel({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
kind: 'traces-drilldown',
createdBy: currentUsername,
})
);
setIsOpen(false);
}, [dispatch, currentUsername]);
const handleAddMetricsDrilldownPanel = useCallback(() => {
dispatch(
addPanel({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
kind: 'metrics-drilldown',
createdBy: currentUsername,
})
);
setIsOpen(false);
}, [dispatch, currentUsername]);
const handleAddProfilesDrilldownPanel = useCallback(() => {
dispatch(
addPanel({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
kind: 'profiles-drilldown',
createdBy: currentUsername,
})
);
setIsOpen(false);
}, [dispatch, currentUsername]);
const handleAddLogsDrilldownPanel = useCallback(() => {
dispatch(
addPanel({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
kind: 'logs-drilldown',
createdBy: currentUsername,
})
);
setIsOpen(false);
}, [dispatch, currentUsername]);
const handleAddFrame = useCallback(() => {
// Get selected panels that are not already in a frame
const selectedUnframedPanels = selectedPanelIds.map((id) => panels[id]).filter((panel) => panel && !panel.frameId);
let position: { x: number; y: number; width: number; height: number };
if (selectedUnframedPanels.length > 0) {
// Calculate bounds around selected unframed panels
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const panel of selectedUnframedPanels) {
minX = Math.min(minX, panel.position.x);
minY = Math.min(minY, panel.position.y);
maxX = Math.max(maxX, panel.position.x + panel.position.width);
maxY = Math.max(maxY, panel.position.y + panel.position.height);
}
// Add padding around the panels
const padding = 50;
position = {
x: minX - padding,
y: minY - padding,
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2,
};
} else {
// No selected panels, position at viewport center
const viewportSize = { width: window.innerWidth, height: window.innerHeight };
const canvasCenterX = (-viewport.panX + viewportSize.width / 2) / viewport.zoom;
const canvasCenterY = (-viewport.panY + viewportSize.height / 2) / viewport.zoom;
const frameWidth = 800;
const frameHeight = 600;
position = {
x: canvasCenterX - frameWidth / 2,
y: canvasCenterY - frameHeight / 2,
width: frameWidth,
height: frameHeight,
};
}
dispatch(
addFrame({
position,
createdBy: currentUsername,
})
);
}, [dispatch, currentUsername, selectedPanelIds, panels, viewport]);
const handleAddPostItNote = useCallback(() => {
dispatch(
addPostItNote({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
createdBy: currentUsername,
})
);
setIsOpen(false);
}, [dispatch, currentUsername]);
const handlePaste = useCallback(async () => {
try {
// Try to read from system clipboard first for cross-canvas support
const text = await navigator.clipboard.readText();
const clipboardData = JSON.parse(text);
// Validate clipboard data structure
if (clipboardData && typeof clipboardData === 'object' && 'mode' in clipboardData && 'width' in clipboardData && 'height' in clipboardData) {
dispatch(
pastePanel({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
createdBy: currentUsername,
clipboardData,
})
);
return;
}
} catch (err) {
// Fall through to use local clipboard
}
// Fallback to local clipboard
dispatch(
pastePanel({
viewportSize: {
width: window.innerWidth,
height: window.innerHeight,
},
createdBy: currentUsername,
})
);
}, [dispatch, currentUsername]);
const handleSetPointerMode = useCallback(() => {
dispatch(setCursorMode({ mode: 'pointer' }));
}, [dispatch]);
const handleSetHandMode = useCallback(() => {
dispatch(setCursorMode({ mode: 'hand' }));
}, [dispatch]);
// Build context for assistant
const canvasContext = useMemo(() => {
const panelsArray = Object.values(panels);
return createAssistantContextItem('structured', {
title: t('explore-map.assistant.canvas-title', 'Explore Canvas'),
data: {
canvasId: mapUid,
panelCount: panelsArray.length,
panels: panelsArray.map((panel) => ({
id: panel.id,
mode: panel.mode,
position: panel.position,
datasourceUid: panel.exploreState?.datasourceUid,
queries: panel.exploreState?.queries,
queryCount: panel.exploreState?.queries?.length || 0,
timeRange: panel.exploreState?.range,
createdBy: panel.createdBy,
})),
},
});
}, [panels, mapUid]);
// Provide component context and additional instructions to the assistant
const componentContext = useMemo(() => {
return createAssistantContextItem('component', {
components: {
AddPanelAction,
},
namespace: 'exploreMap',
hidden: false, // Make visible so we can debug
prompt: `IMPORTANT: You have an interactive component that can add pre-configured panels.
Component: exploreMap_AddPanelAction
Whitelisted props (ONLY these are allowed):
- type: MUST ALWAYS BE "explore" (only explore panels supported)
- namespace: datasource UID (optional - use to specify which datasource)
- metric: query expression (optional - PromQL, LogQL, TraceQL, etc.) - MUST BE URL-ENCODED
- description: custom button text (optional)
- name: display name (optional)
Usage (place directly in response, NEVER in code blocks):
<exploreMap_AddPanelAction type="explore" />
<exploreMap_AddPanelAction type="explore" namespace="prometheus-uid" metric="up" />
<exploreMap_AddPanelAction type="explore" namespace="loki-uid" metric="%7Bjob%3D%22varlogs%22%7D" />
<exploreMap_AddPanelAction type="explore" namespace="prometheus-uid" metric="rate%28http_requests_total%5B5m%5D%29" description="HTTP Request Rate" />
CRITICAL:
- **ALWAYS use type="explore"** - no other types are supported.
- Never wrap components in backticks or code blocks.
- Always URL-encode the metric prop to handle special characters like ( ) [ ] { } " ' etc.`,
});
}, []);
// Provide additional instructions to the assistant (hidden from UI)
const assistantInstructions = useMemo(() => {
return createAssistantContextItem('structured', {
hidden: true,
title: t('explore-map.assistant.capabilities-title', 'Atlas Capabilities'),
data: {
capabilities: [
t(
'explore-map.assistant.capability-add-panels',
'You can help users add new panels to the canvas using the exploreMap_AddPanelAction component'
),
t(
'explore-map.assistant.capability-analyze',
'Analyze the current panels and suggest additional panels that would complement the existing ones'
),
t(
'explore-map.assistant.capability-gaps',
'Identify gaps in observability coverage (missing logs, traces, metrics, or profiles)'
),
t('explore-map.assistant.capability-layouts', 'Suggest panel layouts or organization strategies'),
t(
'explore-map.assistant.capability-relationships',
'Help users understand relationships between panels based on their queries and datasources'
),
],
instructions: t(
'explore-map.assistant.instructions',
'When users ask to add panels, use the exploreMap_AddPanelAction component to provide interactive buttons. You can suggest multiple panels at once by providing multiple component instances. Always explain why you are suggesting specific panel types based on the current canvas state.'
),
},
});
}, []);
const handleOpenAssistant = useCallback(() => {
if (!openAssistant) {
return;
}
reportInteraction('grafana_explore_map_assistant_opened', {
panelCount: Object.keys(panels).length,
canvasId: mapUid,
});
openAssistant({
origin: 'grafana/explore-map',
mode: 'assistant',
prompt:
'Analyze this explore canvas and summarize what queries and data are being visualized. ' +
'Identify any patterns or relationships between the panels. ' +
'If you notice gaps in observability coverage, suggest additional panels I could add using the interactive component.',
context: [canvasContext, componentContext, assistantInstructions],
autoSend: true,
});
}, [openAssistant, panels, mapUid, canvasContext, componentContext, assistantInstructions]);
const MenuActions = () => (
<Menu>
<MenuItem
label={t('explore-map.toolbar.add-panel', 'Add Explore panel')}
icon="compass"
onClick={handleAddPanel}
/>
<MenuItemWithLogo
label={t('explore-map.toolbar.add-metrics-drilldown-panel', 'Add Metrics Drilldown panel')}
logoSrc={prometheusLogoSvg}
logoAlt="Prometheus"
onClick={handleAddMetricsDrilldownPanel}
/>
<MenuItemWithLogo
label={t('explore-map.toolbar.add-logs-drilldown-panel', 'Add Logs Drilldown panel')}
logoSrc={lokiIconSvg}
logoAlt="Loki"
onClick={handleAddLogsDrilldownPanel}
/>
<MenuItemWithLogo
label={t('explore-map.toolbar.add-traces-drilldown-panel', 'Add Traces Drilldown panel')}
logoSrc={tempoLogoSvg}
logoAlt="Tempo"
onClick={handleAddTracesDrilldownPanel}
/>
<MenuItemWithLogo
label={t('explore-map.toolbar.add-profiles-drilldown-panel', 'Add Profiles Drilldown panel')}
logoSrc={pyroscopeIconSvg}
logoAlt="Pyroscope"
onClick={handleAddProfilesDrilldownPanel}
/>
<MenuItem
label={t('explore-map.toolbar.paste-panel', 'Paste panel')}
icon="document-info"
onClick={handlePaste}
disabled={!clipboard && !hasSystemClipboard}
/>
<MenuItem
label={t('explore-map.toolbar.add-sticky', 'Add Sticky note')}
icon="file-alt"
onClick={handleAddPostItNote}
/>
</Menu>
);
return (
<div className={styles.floatingToolbar}>
<div className={styles.cursorModeGroup}>
<button
className={`${styles.cursorModeButton} ${cursorMode === 'pointer' ? styles.cursorModeButtonActive : ''}`}
onClick={handleSetPointerMode}
title={t('explore-map.toolbar.pointer-mode', 'Pointer mode (V)')}
aria-label={t('explore-map.toolbar.pointer-mode-aria', 'Pointer mode')}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className={styles.cursorIcon}>
<path
d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87a.5.5 0 0 0 .35-.85L6.35 2.85a.5.5 0 0 0-.85.35Z"
fill="currentColor"
/>
</svg>
</button>
<button
className={`${styles.cursorModeButton} ${cursorMode === 'hand' ? styles.cursorModeButtonActive : ''}`}
onClick={handleSetHandMode}
title={t('explore-map.toolbar.hand-mode', 'Hand mode (H)')}
aria-label={t('explore-map.toolbar.hand-mode-aria', 'Hand mode')}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className={styles.cursorIcon}>
<path
d="M11.5 8.5V4a1.5 1.5 0 0 0-3 0v6.5m3-2V5a1.5 1.5 0 0 1 3 0v5m0-2.5V5a1.5 1.5 0 0 1 3 0v7m0-2.5V7a1.5 1.5 0 0 1 3 0v8.5c0 2.5-2 4.5-4.5 4.5h-2.5C11 20 9 18.5 9 16v-3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<div className={styles.toolbarDivider} />
<ButtonGroup>
<Button icon="plus" onClick={handleAddPanel} variant="primary">
<Trans i18nKey="explore-map.toolbar.add-panel">Add panel</Trans>
</Button>
<Dropdown overlay={MenuActions} placement="bottom-end" onVisibleChange={setIsOpen}>
<Button
aria-label={t('explore-map.toolbar.add-panel-dropdown', 'Add panel options')}
variant="primary"
icon={isOpen ? 'angle-up' : 'angle-down'}
/>
</Dropdown>
</ButtonGroup>
<Button icon="folder-plus" onClick={handleAddFrame} variant="secondary">
<Trans i18nKey="explore-map.toolbar.add-frame">Add frame</Trans>
</Button>
{isAssistantAvailable && Object.keys(panels).length > 0 && (
<Button icon="ai-sparkle" onClick={handleOpenAssistant} variant="secondary">
<Trans i18nKey="explore-map.toolbar.ask-assistant">Ask Assistant</Trans>
</Button>
)}
</div>
);
}
interface MenuItemWithLogoProps {
label: string;
logoSrc: string;
logoAlt: string;
onClick: () => void;
}
function MenuItemWithLogo({ label, logoSrc, logoAlt, onClick }: MenuItemWithLogoProps) {
const styles = useStyles2(getMenuItemStyles);
return (
<div className={styles.menuItemWrapper}>
<img src={logoSrc} alt={logoAlt} className={styles.logo} aria-hidden="true" />
<MenuItem label={label} onClick={onClick} className={styles.menuItemWithLogo} />
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
floatingToolbar: css({
position: 'fixed',
bottom: theme.spacing(3),
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(1, 2),
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
zIndex: 1000,
}),
cursorModeGroup: css({
display: 'flex',
gap: '2px',
backgroundColor: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
padding: '2px',
}),
cursorModeButton: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
border: 'none',
borderRadius: theme.shape.radius.default,
backgroundColor: 'transparent',
color: theme.colors.text.secondary,
cursor: 'pointer',
[theme.transitions.handleMotion('no-preference')]: {
transition: 'all 0.2s ease',
},
'&:hover': {
backgroundColor: theme.colors.action.hover,
color: theme.colors.text.primary,
},
}),
cursorModeButtonActive: css({
backgroundColor: theme.colors.background.primary,
color: theme.colors.text.primary,
boxShadow: theme.shadows.z1,
}),
cursorIcon: css({
width: '20px',
height: '20px',
}),
toolbarDivider: css({
width: '1px',
height: '32px',
backgroundColor: theme.colors.border.weak,
margin: `0 ${theme.spacing(1)}`,
}),
};
};
const getMenuItemStyles = (theme: GrafanaTheme2) => {
return {
menuItemWrapper: css({
position: 'relative',
}),
logo: css({
position: 'absolute',
left: theme.spacing(1.5),
top: '50%',
transform: 'translateY(-50%)',
width: '16px',
height: '16px',
flexShrink: 0,
zIndex: 1,
pointerEvents: 'none',
}),
menuItemWithLogo: css({
paddingLeft: theme.spacing(4.5), // Make room for the logo
}),
};
};

View File

@@ -0,0 +1,764 @@
import { css, cx } from '@emotion/css';
import React, { useCallback, useState, useRef } from 'react';
import ReactDOM from 'react-dom';
import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
import { useClickAway } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import {
updateFramePosition,
updateFrameSize,
updateFrameTitle,
updateFrameColor,
updateFrameEmoji,
setActiveFrameDrag,
clearActiveFrameDrag,
associatePanelWithFrame,
disassociatePanelFromFrame,
removeFrame,
} from '../state/crdtSlice';
import { selectViewport, selectPanelsInFrame, selectPanels, selectFrames } from '../state/selectors';
import { ExploreMapFrame as Frame } from '../state/types';
import { ConfirmDeleteFrameDialog } from './ConfirmDeleteFrameDialog';
interface ExploreMapFrameProps {
frame: Frame;
}
// Predefined color palette for frames
const FRAME_COLORS = [
{ name: 'Blue', value: '#6e9fff' },
{ name: 'Green', value: '#73bf69' },
{ name: 'Yellow', value: '#fade2a' },
{ name: 'Orange', value: '#ff9830' },
{ name: 'Red', value: '#f2495c' },
{ name: 'Purple', value: '#b877d9' },
{ name: 'Pink', value: '#fe85b4' },
{ name: 'Cyan', value: '#5dc4cd' },
{ name: 'Gray', value: '#9fa7b3' },
];
// Predefined emoji options for frames
const FRAME_EMOJIS = [
'📦', '📊', '📈', '🎯', '🔥', '⭐', '💡', '🚀',
'📝', '🎨', '🔧', '⚙️', '🏆', '🎪', '🌟', '💼',
'📌', '🔍', '📱', '💻', '🖥️', '⚡', '🌈', '🎭',
];
function ExploreMapFrameComponent({ frame }: ExploreMapFrameProps) {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const panelsInFrame = useSelector((state) => selectPanelsInFrame(state.exploreMapCRDT, frame.id));
const allPanels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const allFrames = useSelector((state) => selectFrames(state.exploreMapCRDT));
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleValue, setTitleValue] = useState(frame.title);
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showColorPicker, setShowColorPicker] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const colorPickerRef = useRef<HTMLDivElement>(null);
const emojiPickerRef = useRef<HTMLDivElement>(null);
const colorButtonRef = useRef<HTMLButtonElement>(null);
const emojiButtonRef = useRef<HTMLButtonElement>(null);
const [colorPickerPosition, setColorPickerPosition] = useState<{ top: number; left: number } | null>(null);
const [emojiPickerPosition, setEmojiPickerPosition] = useState<{ top: number; left: number } | null>(null);
// Track current size during resize for visual feedback
const [currentSize, setCurrentSize] = useState({
width: frame.position.width,
height: frame.position.height,
});
// Sync currentSize when frame position changes (from remote updates or after resize completes)
React.useEffect(() => {
setCurrentSize({
width: frame.position.width,
height: frame.position.height,
});
}, [frame.position.width, frame.position.height]);
// Check if two rectangles overlap
const checkRectOverlap = useCallback(
(
x1: number,
y1: number,
w1: number,
h1: number,
x2: number,
y2: number,
w2: number,
h2: number
): boolean => {
return !(x1 + w1 <= x2 || x2 + w2 <= x1 || y1 + h1 <= y2 || y2 + h2 <= y1);
},
[]
);
// Check if a proposed position would cause overlap with other frames
const wouldOverlapOtherFrames = useCallback(
(x: number, y: number, width: number, height: number): boolean => {
for (const [frameId, otherFrame] of Object.entries(allFrames)) {
// Skip the current frame
if (frameId === frame.id) {
continue;
}
if (
checkRectOverlap(
x,
y,
width,
height,
otherFrame.position.x,
otherFrame.position.y,
otherFrame.position.width,
otherFrame.position.height
)
) {
return true;
}
}
return false;
},
[allFrames, frame.id, checkRectOverlap]
);
// Constrain position to avoid overlaps with other frames
const constrainPosition = useCallback(
(proposedX: number, proposedY: number, width: number, height: number): { x: number; y: number } => {
// If no overlap, use the proposed position
if (!wouldOverlapOtherFrames(proposedX, proposedY, width, height)) {
return { x: proposedX, y: proposedY };
}
// Try to find the nearest valid position by checking nearby positions
// We'll use the last valid position if we can't find one
return {
x: frame.position.x,
y: frame.position.y,
};
},
[frame.position.x, frame.position.y, wouldOverlapOtherFrames]
);
// Check if a panel is >50% inside the frame bounds
const isPanelInFrame = useCallback(
(
panelX: number,
panelY: number,
panelWidth: number,
panelHeight: number,
frameX: number,
frameY: number,
frameWidth: number,
frameHeight: number
): boolean => {
const panelLeft = panelX;
const panelRight = panelX + panelWidth;
const panelTop = panelY;
const panelBottom = panelY + panelHeight;
const frameLeft = frameX;
const frameRight = frameX + frameWidth;
const frameTop = frameY;
const frameBottom = frameY + frameHeight;
// Calculate intersection area
const intersectLeft = Math.max(panelLeft, frameLeft);
const intersectRight = Math.min(panelRight, frameRight);
const intersectTop = Math.max(panelTop, frameTop);
const intersectBottom = Math.min(panelBottom, frameBottom);
if (intersectRight > intersectLeft && intersectBottom > intersectTop) {
const intersectArea = (intersectRight - intersectLeft) * (intersectBottom - intersectTop);
const panelArea = panelWidth * panelHeight;
// If >50% of panel is inside frame, consider it contained
return intersectArea / panelArea > 0.5;
}
return false;
},
[]
);
// Update panel-frame associations based on current frame bounds
// This should only handle NEW panels entering the frame, not existing associations
const updatePanelAssociations = useCallback(
(frameX: number, frameY: number, frameWidth: number, frameHeight: number, skipExisting = false) => {
for (const [panelId, panel] of Object.entries(allPanels)) {
// Skip panels that are already associated with this frame if skipExisting is true
// This prevents recalculating offsets for panels that moved with the frame
if (skipExisting && panel.frameId === frame.id) {
continue;
}
const isInside = isPanelInFrame(
panel.position.x,
panel.position.y,
panel.position.width,
panel.position.height,
frameX,
frameY,
frameWidth,
frameHeight
);
if (isInside && panel.frameId !== frame.id) {
// Panel moved into this frame
const offsetX = panel.position.x - frameX;
const offsetY = panel.position.y - frameY;
dispatch(
associatePanelWithFrame({
panelId,
frameId: frame.id,
offsetX,
offsetY,
})
);
} else if (!isInside && panel.frameId === frame.id) {
// Panel moved out of this frame
dispatch(
disassociatePanelFromFrame({
panelId,
})
);
}
}
},
[allPanels, frame.id, isPanelInFrame, dispatch]
);
const handleDragStart: RndDragCallback = useCallback(
(_e, data) => {
setDragStartPos({ x: data.x, y: data.y });
},
[]
);
const handleDrag: RndDragCallback = useCallback(
(_e, data) => {
if (!dragStartPos) {
return;
}
// Update local state to show frame and child panels moving in real-time
const deltaX = data.x - dragStartPos.x;
const deltaY = data.y - dragStartPos.y;
dispatch(setActiveFrameDrag({
draggedFrameId: frame.id,
deltaX,
deltaY,
}));
},
[dispatch, frame.id, dragStartPos]
);
const handleDragStop: RndDragCallback = useCallback(
(_e, data) => {
// Clear the active frame drag state
dispatch(clearActiveFrameDrag());
// Apply collision detection to constrain the position
const constrainedPos = constrainPosition(data.x, data.y, frame.position.width, frame.position.height);
// Update the frame position (this will move existing child panels)
dispatch(
updateFramePosition({
frameId: frame.id,
x: constrainedPos.x,
y: constrainedPos.y,
})
);
// Check for capturing NEW panels (don't disassociate existing ones)
// Skip existing panels to avoid recalculating their offsets after they've moved with the frame
setTimeout(() => {
updatePanelAssociations(constrainedPos.x, constrainedPos.y, frame.position.width, frame.position.height, true);
}, 0);
setDragStartPos(null);
},
[dispatch, frame.id, frame.position.width, frame.position.height, constrainPosition, updatePanelAssociations]
);
const handleResize: RndResizeCallback = useCallback(
(_e, _direction, ref) => {
// Update current size during resize for visual feedback
setCurrentSize({
width: ref.offsetWidth,
height: ref.offsetHeight,
});
},
[]
);
const handleResizeStop: RndResizeCallback = useCallback(
(_e, _direction, ref, _delta, position) => {
const newWidth = ref.offsetWidth;
const newHeight = ref.offsetHeight;
// Check if the new size would cause overlap
if (wouldOverlapOtherFrames(position.x, position.y, newWidth, newHeight)) {
// Revert to the original size if overlap detected
setCurrentSize({
width: frame.position.width,
height: frame.position.height,
});
return;
}
// Update local size state
setCurrentSize({
width: newWidth,
height: newHeight,
});
dispatch(
updateFramePosition({
frameId: frame.id,
x: position.x,
y: position.y,
})
);
dispatch(
updateFrameSize({
frameId: frame.id,
width: newWidth,
height: newHeight,
})
);
// Check for panel associations AFTER updating frame
// This ensures we check against the new frame size
setTimeout(() => {
updatePanelAssociations(position.x, position.y, newWidth, newHeight);
}, 0);
},
[dispatch, frame.id, frame.position.width, frame.position.height, wouldOverlapOtherFrames, updatePanelAssociations]
);
const handleTitleDoubleClick = useCallback(() => {
setIsEditingTitle(true);
setTitleValue(frame.title);
}, [frame.title]);
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTitleValue(e.target.value);
}, []);
const handleTitleBlur = useCallback(() => {
if (titleValue.trim() && titleValue !== frame.title) {
dispatch(
updateFrameTitle({
frameId: frame.id,
title: titleValue.trim(),
})
);
}
setIsEditingTitle(false);
}, [dispatch, frame.id, titleValue, frame.title]);
const handleTitleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleTitleBlur();
} else if (e.key === 'Escape') {
setIsEditingTitle(false);
setTitleValue(frame.title);
}
},
[handleTitleBlur, frame.title]
);
const handleDeleteClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// If the frame has panels, show the confirmation dialog
// Otherwise, delete the frame immediately
if (panelsInFrame.length > 0) {
setShowDeleteDialog(true);
} else {
dispatch(removeFrame({ frameId: frame.id }));
}
},
[panelsInFrame.length, dispatch, frame.id]
);
const handleCloseDeleteDialog = useCallback(() => {
setShowDeleteDialog(false);
}, []);
// Close pickers when clicking outside
useClickAway(colorPickerRef, () => {
setShowColorPicker(false);
});
useClickAway(emojiPickerRef, () => {
setShowEmojiPicker(false);
});
const handleColorButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (!showColorPicker && colorButtonRef.current) {
const rect = colorButtonRef.current.getBoundingClientRect();
setColorPickerPosition({
top: rect.bottom + 4,
left: rect.left,
});
}
setShowColorPicker(!showColorPicker);
setShowEmojiPicker(false);
},
[showColorPicker]
);
const handleColorSelect = useCallback(
(color: string) => {
dispatch(
updateFrameColor({
frameId: frame.id,
color,
})
);
setShowColorPicker(false);
},
[dispatch, frame.id]
);
const handleEmojiButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (!showEmojiPicker && emojiButtonRef.current) {
const rect = emojiButtonRef.current.getBoundingClientRect();
setEmojiPickerPosition({
top: rect.bottom + 4,
left: rect.left,
});
}
setShowEmojiPicker(!showEmojiPicker);
setShowColorPicker(false);
},
[showEmojiPicker]
);
const handleEmojiSelect = useCallback(
(emoji: string) => {
dispatch(
updateFrameEmoji({
frameId: frame.id,
emoji,
})
);
setShowEmojiPicker(false);
},
[dispatch, frame.id]
);
return (
<Rnd
position={{ x: frame.position.x, y: frame.position.y }}
size={{ width: frame.position.width, height: frame.position.height }}
scale={viewport.zoom}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragStop={handleDragStop}
onResize={handleResize}
onResizeStop={handleResizeStop}
bounds="parent"
dragHandleClassName="frame-drag-handle"
className={styles.frameContainer}
style={{ zIndex: frame.position.zIndex }}
minWidth={400}
minHeight={300}
>
<div className={styles.frame}>
{/* Border overlay that scales inversely with zoom to maintain constant visual width */}
<div
className={styles.frameBorder}
style={{
width: `${currentSize.width * viewport.zoom}px`,
height: `${currentSize.height * viewport.zoom}px`,
transform: `scale(${1 / viewport.zoom})`,
transformOrigin: 'top left',
borderColor: frame.color || undefined,
}}
/>
<div
className={styles.frameHeader + ' frame-drag-handle'}
style={{
width: `${currentSize.width * viewport.zoom}px`,
top: `${-30 / viewport.zoom}px`,
transform: `scale(${1 / viewport.zoom})`,
transformOrigin: 'top left',
borderColor: frame.color || undefined,
}}
>
{/* Emoji button */}
<button
ref={emojiButtonRef}
className={styles.emojiButton}
onClick={handleEmojiButtonClick}
aria-label="Change emoji"
>
{frame.emoji || '🔍'}
</button>
{/* Title display/input */}
{isEditingTitle ? (
<input
className={styles.frameTitleInput}
value={titleValue}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className={styles.frameTitle} onDoubleClick={handleTitleDoubleClick}>
{frame.title}
</div>
)}
{/* Color picker button */}
<button
ref={colorButtonRef}
className={styles.colorButton}
onClick={handleColorButtonClick}
style={{ backgroundColor: frame.color || '#6e9fff' }}
aria-label="Change frame color"
/>
{/* Delete button */}
<IconButton
name="trash-alt"
size="sm"
variant="secondary"
onClick={handleDeleteClick}
tooltip="Delete frame"
className={styles.deleteButton}
/>
</div>
</div>
{showDeleteDialog && (
<ConfirmDeleteFrameDialog
frameId={frame.id}
frameTitle={frame.title}
panelCount={panelsInFrame.length}
onClose={handleCloseDeleteDialog}
/>
)}
{/* Render pickers via portal to escape z-index stacking context */}
{showEmojiPicker && emojiPickerPosition && ReactDOM.createPortal(
<div
ref={emojiPickerRef}
className={styles.emojiPicker}
style={{
position: 'fixed',
top: `${emojiPickerPosition.top}px`,
left: `${emojiPickerPosition.left}px`,
}}
>
{FRAME_EMOJIS.map((emoji) => (
<button
key={emoji}
className={cx(styles.emojiOption, frame.emoji === emoji && styles.emojiOptionSelected)}
onClick={() => handleEmojiSelect(emoji)}
>
{emoji}
</button>
))}
</div>,
document.body
)}
{showColorPicker && colorPickerPosition && ReactDOM.createPortal(
<div
ref={colorPickerRef}
className={styles.colorPicker}
style={{
position: 'fixed',
top: `${colorPickerPosition.top}px`,
left: `${colorPickerPosition.left}px`,
}}
>
{FRAME_COLORS.map((color) => (
<button
key={color.value}
className={cx(styles.colorOption, frame.color === color.value && styles.colorOptionSelected)}
style={{ backgroundColor: color.value }}
onClick={() => handleColorSelect(color.value)}
title={color.name}
/>
))}
</div>,
document.body
)}
</Rnd>
);
}
export const ExploreMapFrame = React.memo(ExploreMapFrameComponent);
const getStyles = (theme: GrafanaTheme2) => ({
frameContainer: css({
cursor: 'default',
// Container needs pointer events for resize handles to work
// But we'll make the interior non-interactive
'& .react-resizable-handle': {
pointerEvents: 'auto',
zIndex: 10, // Ensure handles are above everything
},
}),
frame: css({
width: '100%',
height: '100%',
position: 'relative',
backgroundColor: 'transparent',
pointerEvents: 'none', // Frame interior doesn't intercept clicks - lets them pass through to panels
}),
frameBorder: css({
position: 'absolute',
top: 0,
left: 0,
border: `2px solid ${theme.colors.border.strong}`,
borderRadius: theme.shape.radius.default,
pointerEvents: 'none',
}),
frameHeader: css({
position: 'absolute',
top: '-30px',
left: '0',
width: '100%',
maxWidth: '100%',
overflow: 'hidden',
minWidth: 0,
padding: theme.spacing(0.5, 1),
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.strong}`,
borderRadius: theme.shape.radius.default,
cursor: 'move',
pointerEvents: 'auto', // Header is interactive
userSelect: 'none',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
deleteButton: css({
cursor: 'pointer',
}),
emojiButton: css({
fontSize: '20px',
cursor: 'pointer',
userSelect: 'none',
padding: theme.spacing(0.25, 0.5),
border: `1px solid ${theme.colors.border.medium}`,
borderRadius: theme.shape.radius.default,
backgroundColor: theme.colors.background.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': {
backgroundColor: theme.colors.background.secondary,
},
}),
emojiPicker: css({
display: 'grid',
gridTemplateColumns: 'repeat(8, 1fr)',
gap: theme.spacing(0.5),
padding: theme.spacing(1),
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.medium}`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
zIndex: 10000,
minWidth: '240px',
}),
emojiOption: css({
fontSize: '20px',
padding: theme.spacing(0.5),
border: `1px solid transparent`,
borderRadius: theme.shape.radius.default,
backgroundColor: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': {
backgroundColor: theme.colors.background.secondary,
},
}),
emojiOptionSelected: css({
border: `1px solid ${theme.colors.primary.border}`,
backgroundColor: theme.colors.action.selected,
}),
frameTitle: css({
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
flex: '1 1 auto',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
frameTitleInput: css({
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
padding: theme.spacing(0.5),
border: 'none',
outline: `2px solid ${theme.colors.primary.border}`,
backgroundColor: theme.colors.background.primary,
borderRadius: theme.shape.radius.default,
flex: '1 1 auto',
minWidth: 0,
}),
colorButton: css({
width: '24px',
height: '24px',
padding: 0,
border: `1px solid ${theme.colors.border.medium}`,
borderRadius: theme.shape.radius.default,
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
},
}),
colorPicker: css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: theme.spacing(0.5),
padding: theme.spacing(1),
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.medium}`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
zIndex: 10000,
}),
colorOption: css({
width: '32px',
height: '32px',
padding: 0,
border: `2px solid transparent`,
borderRadius: theme.shape.radius.default,
cursor: 'pointer',
'&:hover': {
opacity: 0.8,
},
}),
colorOptionSelected: css({
border: `2px solid ${theme.colors.text.primary}`,
}),
});

View File

@@ -0,0 +1,533 @@
import { css, cx } from '@emotion/css';
import React, { useCallback, useRef, useState } from 'react';
import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createSuccessNotification } from 'app/core/copy/appNotification';
import { useDispatch, useSelector } from 'app/types/store';
import { splitClose } from '../../explore/state/main';
import {
associatePanelWithFrame,
bringPanelToFront,
clearActiveDrag,
copyPanel,
disassociatePanelFromFrame,
duplicatePanel,
removePanel,
selectPanel,
setActiveDrag,
updateMultiplePanelPositions,
updatePanelPosition,
updatePanelSize,
} from '../state/crdtSlice';
import { selectSelectedPanelIds, selectViewport, selectCursors, selectFrames } from '../state/selectors';
import { ExploreMapPanel } from '../state/types';
import { ExploreMapPanelContent } from './ExploreMapPanelContent';
interface ExploreMapPanelContainerProps {
panel: ExploreMapPanel;
}
function ExploreMapPanelContainerComponent({ panel }: ExploreMapPanelContainerProps) {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const rndRef = useRef<Rnd>(null);
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
const selectedPanelIds = useSelector((state) => selectSelectedPanelIds(state.exploreMapCRDT));
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const cursors = useSelector((state) => selectCursors(state.exploreMapCRDT));
const frames = useSelector((state) => selectFrames(state.exploreMapCRDT));
const isSelected = selectedPanelIds.includes(panel.id);
// Find all users who have this panel selected (excluding current user)
const remoteSelectingUsers = Object.values(cursors).filter(
(cursor) => cursor.selectedPanelIds?.includes(panel.id)
);
// Get the active drag info from a parent context (if any panel is being dragged)
const activeDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeDrag);
// Get active frame drag info
const activeFrameDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeFrameDrag);
// Check if panel intersects with any frame (>50% overlap)
const checkFrameIntersection = useCallback((
panelX: number,
panelY: number,
panelWidth: number,
panelHeight: number
): string | null => {
for (const frame of Object.values(frames)) {
const panelLeft = panelX;
const panelRight = panelX + panelWidth;
const panelTop = panelY;
const panelBottom = panelY + panelHeight;
const frameLeft = frame.position.x;
const frameRight = frame.position.x + frame.position.width;
const frameTop = frame.position.y;
const frameBottom = frame.position.y + frame.position.height;
// Calculate intersection area
const intersectLeft = Math.max(panelLeft, frameLeft);
const intersectRight = Math.min(panelRight, frameRight);
const intersectTop = Math.max(panelTop, frameTop);
const intersectBottom = Math.min(panelBottom, frameBottom);
if (intersectRight > intersectLeft && intersectBottom > intersectTop) {
const intersectArea = (intersectRight - intersectLeft) * (intersectBottom - intersectTop);
const panelArea = panelWidth * panelHeight;
// If >50% of panel is inside frame, consider it contained
if (intersectArea / panelArea > 0.5) {
return frame.id;
}
}
}
return null;
}, [frames]);
// Calculate effective position considering active drag
let effectiveX = panel.position.x;
let effectiveY = panel.position.y;
// If another panel in the selection is being dragged, apply the offset to this panel
if (activeDragInfo && activeDragInfo.draggedPanelId !== panel.id && isSelected) {
effectiveX += activeDragInfo.deltaX;
effectiveY += activeDragInfo.deltaY;
}
// If this panel's frame is being dragged, apply the frame drag offset
if (activeFrameDragInfo && panel.frameId === activeFrameDragInfo.draggedFrameId) {
effectiveX += activeFrameDragInfo.deltaX;
effectiveY += activeFrameDragInfo.deltaY;
}
const handleDragStart: RndDragCallback = useCallback(
(_e, data) => {
setDragStartPos({ x: data.x, y: data.y });
},
[]
);
const handleDrag: RndDragCallback = useCallback(
(_e, data) => {
if (!dragStartPos) {
return;
}
// If dragging multiple panels, store the drag offset in local state
// This will cause other panels to visually move without CRDT operations
if (isSelected && selectedPanelIds.length > 1) {
dispatch(setActiveDrag({
draggedPanelId: panel.id,
deltaX: data.x - panel.position.x,
deltaY: data.y - panel.position.y,
}));
}
},
[dispatch, panel.id, panel.position.x, panel.position.y, dragStartPos, isSelected, selectedPanelIds.length]
);
const handleDragStop: RndDragCallback = useCallback(
(_e, data) => {
// Clear the active drag state
dispatch(clearActiveDrag());
if (dragStartPos) {
const deltaX = data.x - dragStartPos.x;
const deltaY = data.y - dragStartPos.y;
// Only update position if there was actual movement
const hasMoved = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
if (hasMoved) {
if (isSelected && selectedPanelIds.length > 1) {
// Multi-panel drag: update all selected panels with the delta
// This creates CRDT operations that will be broadcast
dispatch(
updateMultiplePanelPositions({
panelId: panel.id,
deltaX,
deltaY,
})
);
}
// Always update the dragged panel's final position
dispatch(
updatePanelPosition({
panelId: panel.id,
x: data.x,
y: data.y,
})
);
// Check if panel should be associated with a frame
const intersectingFrameId = checkFrameIntersection(
data.x,
data.y,
panel.position.width,
panel.position.height
);
if (intersectingFrameId && intersectingFrameId !== panel.frameId) {
// Panel moved into a frame
const frame = frames[intersectingFrameId];
const offsetX = data.x - frame.position.x;
const offsetY = data.y - frame.position.y;
dispatch(
associatePanelWithFrame({
panelId: panel.id,
frameId: intersectingFrameId,
offsetX,
offsetY,
})
);
} else if (!intersectingFrameId && panel.frameId) {
// Panel moved out of frame
dispatch(
disassociatePanelFromFrame({
panelId: panel.id,
})
);
}
}
}
setDragStartPos(null);
},
[
dispatch,
panel.id,
panel.position.width,
panel.position.height,
panel.frameId,
dragStartPos,
isSelected,
selectedPanelIds.length,
checkFrameIntersection,
frames,
]
);
const handleResizeStop: RndResizeCallback = useCallback(
(_e, _direction, ref, _delta, position) => {
const newWidth = ref.offsetWidth;
const newHeight = ref.offsetHeight;
// Update position
dispatch(
updatePanelPosition({
panelId: panel.id,
x: position.x,
y: position.y,
})
);
// Update size
dispatch(
updatePanelSize({
panelId: panel.id,
width: newWidth,
height: newHeight,
})
);
// Trigger resize event for Explore components
window.dispatchEvent(new Event('resize'));
},
[dispatch, panel.id]
);
const handleMouseDown = useCallback(
(e: MouseEvent) => {
// Check for Cmd (Mac) or Ctrl (Windows/Linux) key
const isMultiSelect = e.metaKey || e.ctrlKey;
// If this panel is already selected and we're not multi-selecting,
// don't change selection (allows dragging multiple selected panels)
if (isSelected && !isMultiSelect) {
dispatch(bringPanelToFront({ panelId: panel.id }));
return;
}
dispatch(selectPanel({ panelId: panel.id, addToSelection: isMultiSelect }));
// Only bring to front if not multi-selecting
if (!isMultiSelect) {
dispatch(bringPanelToFront({ panelId: panel.id }));
}
},
[dispatch, panel.id, isSelected]
);
const handleRemove = useCallback(
() => {
// Clean up Explore state first
dispatch(splitClose(panel.exploreId));
// Then remove panel
dispatch(removePanel({ panelId: panel.id }));
},
[dispatch, panel.id, panel.exploreId]
);
const handleDuplicate = useCallback(
() => {
dispatch(duplicatePanel({ panelId: panel.id }));
},
[dispatch, panel.id]
);
const handleCopy = useCallback(
async () => {
dispatch(copyPanel({ panelId: panel.id }));
// Also copy to system clipboard for cross-canvas support
try {
const panelData = {
mode: panel.mode,
width: panel.position.width,
height: panel.position.height,
exploreState: panel.exploreState,
iframeUrl: panel.iframeUrl,
createdBy: panel.createdBy,
};
await navigator.clipboard.writeText(JSON.stringify(panelData));
dispatch(notifyApp(createSuccessNotification('Panel copied', 'You can paste it on any canvas from the toolbar')));
} catch (err) {
// Fallback if clipboard API fails
dispatch(notifyApp(createSuccessNotification('Panel copied', 'You can paste it from the toolbar on this canvas')));
}
},
[dispatch, panel.id, panel.mode, panel.position, panel.exploreState, panel.iframeUrl, panel.createdBy]
);
const handleInfoClick = useCallback(() => {
// Info click doesn't do anything, just shows the description
}, []);
// Build tooltip content for panel info
const getInfoTooltipContent = useCallback(() => {
if (panel.createdBy) {
return t('explore-map.panel.info.created-by', 'Created by: {{creator}}', { creator: panel.createdBy });
}
return t('explore-map.panel.info.no-creator', 'Creator unknown');
}, [panel.createdBy]);
return (
<Rnd
ref={rndRef}
position={{ x: effectiveX, y: effectiveY }}
size={{ width: panel.position.width, height: panel.position.height }}
scale={viewport.zoom}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
onMouseDown={handleMouseDown}
bounds="parent"
dragHandleClassName="panel-drag-handle"
className={cx(styles.panelContainer, { [styles.selectedPanel]: isSelected })}
style={{ zIndex: panel.position.zIndex }}
minWidth={300}
minHeight={200}
>
<div className={styles.panelWrapper}>
{/* Render remote user selection outlines */}
{remoteSelectingUsers.map((user, index) => (
<div
key={user.sessionId}
className={styles.remoteSelection}
style={{
borderColor: user.color,
// Offset each outline slightly so multiple selections are visible
inset: `${-2 - index * 3}px`,
}}
/>
))}
<div className={styles.panel}>
<div className={cx(styles.panelHeader, 'panel-drag-handle')}>
<div className={styles.panelTitle}>
{t('explore-map.panel.title', 'Explore Panel {{id}}', { id: panel.id.slice(0, 8) })}
</div>
<div className={styles.panelHeaderRight}>
{remoteSelectingUsers.length > 0 && (
<div className={styles.remoteUsers}>
{remoteSelectingUsers.map((user) => (
<span key={user.sessionId} className={styles.remoteUserBadge} style={{ backgroundColor: user.color }}>
{user.userName}
</span>
))}
</div>
)}
<div className={styles.panelActions}>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t('explore-map.panel.info', 'Panel information')}
icon="info-circle"
onClick={handleInfoClick}
description={getInfoTooltipContent()}
/>
<Menu.Item
label={t('explore-map.panel.copy', 'Copy panel')}
icon="copy"
onClick={handleCopy}
/>
<Menu.Item
label={t('explore-map.panel.duplicate', 'Duplicate panel')}
icon="apps"
onClick={handleDuplicate}
/>
<Menu.Divider />
<Menu.Item
label={t('explore-map.panel.remove', 'Remove')}
icon="times"
onClick={handleRemove}
/>
</Menu>
}
placement="bottom-end"
>
<Button
icon="ellipsis-v"
variant="secondary"
size="sm"
fill="text"
aria-label={t('explore-map.panel.actions', 'Panel actions')}
/>
</Dropdown>
</div>
</div>
</div>
<div className={styles.panelContent}>
<ExploreMapPanelContent
panelId={panel.id}
exploreId={panel.exploreId}
width={panel.position.width}
height={panel.position.height - 36}
remoteVersion={panel.remoteVersion}
mode={panel.mode}
exploreState={panel.exploreState}
/>
</div>
</div>
</div>
</Rnd>
);
}
export const ExploreMapPanelContainer = React.memo(ExploreMapPanelContainerComponent, (prevProps, nextProps) => {
const prev = prevProps.panel;
const next = nextProps.panel;
// Only re-render if these specific properties change
// This prevents re-renders when unrelated panels update
return (
prev.id === next.id &&
prev.position.x === next.position.x &&
prev.position.y === next.position.y &&
prev.position.width === next.position.width &&
prev.position.height === next.position.height &&
prev.position.zIndex === next.position.zIndex &&
prev.remoteVersion === next.remoteVersion &&
prev.exploreId === next.exploreId &&
prev.mode === next.mode
);
});
const getStyles = (theme: GrafanaTheme2) => {
return {
panelContainer: css({
cursor: 'default',
}),
panelWrapper: css({
position: 'relative',
width: '100%',
height: '100%',
}),
remoteSelection: css({
position: 'absolute',
pointerEvents: 'none',
border: '2px solid',
borderRadius: theme.shape.radius.default,
zIndex: 1,
}),
panel: css({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
overflow: 'hidden',
boxShadow: theme.shadows.z2,
position: 'relative',
zIndex: 2,
}),
selectedPanel: css({
'& > div > div': {
border: `2px solid ${theme.colors.primary.border}`,
},
}),
panelHeader: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(1, 1.5),
backgroundColor: theme.colors.background.secondary,
borderBottom: `1px solid ${theme.colors.border.weak}`,
cursor: 'move',
minHeight: '36px',
}),
panelTitle: css({
fontSize: theme.typography.body.fontSize,
fontWeight: theme.typography.fontWeightMedium,
userSelect: 'none',
flex: 1,
minWidth: 0, // Allow text truncation
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
panelHeaderRight: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
flexShrink: 0, // Prevent shrinking
}),
remoteUsers: css({
display: 'flex',
gap: theme.spacing(0.5),
alignItems: 'center',
}),
remoteUserBadge: css({
fontSize: theme.typography.bodySmall.fontSize,
padding: theme.spacing(0.25, 0.75),
borderRadius: theme.shape.radius.default,
color: 'white',
fontWeight: theme.typography.fontWeightMedium,
userSelect: 'none',
whiteSpace: 'nowrap',
}),
panelActions: css({
display: 'flex',
gap: theme.spacing(0.5),
}),
panelContent: css({
flex: 1,
overflow: 'hidden',
position: 'relative',
}),
};
};

View File

@@ -0,0 +1,240 @@
import { css } from '@emotion/css';
import React, { useEffect, useMemo, useState } from 'react';
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types/store';
import { ExplorePaneContainer } from '../../explore/ExplorePaneContainer';
import { DEFAULT_RANGE } from '../../explore/state/constants';
import { initializeExplore } from '../../explore/state/explorePane';
import { usePanelStateSync } from '../hooks/usePanelStateSync';
// import { useExploreStateReceiver } from '../hooks/useExploreStateReceiver';
// import { useExploreStateSync } from '../hooks/useExploreStateSync';
import { ExploreMapLogsDrilldownPanel } from './Drilldown/ExploreMapLogsDrilldownPanel';
import { ExploreMapMetricsDrilldownPanel } from './Drilldown/ExploreMapMetricsDrilldownPanel';
import { ExploreMapProfilesDrilldownPanel } from './Drilldown/ExploreMapProfilesDrilldownPanel';
import { ExploreMapTracesDrilldownPanel } from './Drilldown/ExploreMapTracesDrilldownPanel';
interface ExploreMapPanelContentProps {
panelId: string;
exploreId: string;
width: number;
height: number;
remoteVersion?: number;
mode?: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown';
exploreState?: any;
}
// Patch getBoundingClientRect to fix AutoSizer measurements with CSS transforms
// Store original function
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
let isPatched = false;
function patchGetBoundingClientRect() {
if (isPatched) {
return;
}
Element.prototype.getBoundingClientRect = function (this: Element) {
const result = originalGetBoundingClientRect.call(this);
// Very conservative: only patch if this is an HTMLElement with offsetWidth available
// and it's inside a transformed container
if (!(this instanceof HTMLElement)) {
return result;
}
// Check if we should use offsetWidth by looking at the stack trace
// This is hacky but safer than checking DOM position which can fail
try {
// Only apply fix if called from AutoSizer context
const stack = new Error().stack || '';
const isFromAutoSizer = stack.includes('AutoSizer') || stack.includes('_onResize');
if (!isFromAutoSizer) {
return result;
}
// Check if this element has transforms applied
let element: Element | null = this;
while (element) {
const style = window.getComputedStyle(element);
if (style.transform && style.transform !== 'none') {
// Use offsetWidth which is not affected by transforms
return new DOMRect(
result.left,
result.top,
this.offsetWidth,
this.offsetHeight
);
}
element = element.parentElement;
}
} catch (e) {
// If anything fails, return original result
return result;
}
return result;
};
isPatched = true;
}
export const ExploreMapPanelContent = React.memo(function ExploreMapPanelContent({ panelId, exploreId, width, height, remoteVersion, mode, exploreState }: ExploreMapPanelContentProps) {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const [isInitialized, setIsInitialized] = useState(false);
// Create scoped event bus for this panel
const eventBus = useMemo(() => new EventBusSrv(), []);
// Sync panel state when deselected (outgoing)
usePanelStateSync({
panelId,
exploreId,
});
// TODO: Re-enable once we fix the re-rendering issue
// Sync Explore state changes to CRDT (outgoing)
// useExploreStateSync({
// panelId,
// exploreId,
// enabled: isInitialized,
// });
// Receive and apply Explore state changes from CRDT (incoming)
// useExploreStateReceiver({
// panelId,
// exploreId,
// enabled: isInitialized,
// });
// Patch getBoundingClientRect on mount
useEffect(() => {
patchGetBoundingClientRect();
}, []);
// Initialize Explore pane on mount (only for standard Explore panels)
useEffect(() => {
if (
mode === 'traces-drilldown' ||
mode === 'metrics-drilldown' ||
mode === 'profiles-drilldown' ||
mode === 'logs-drilldown'
) {
// Drilldown panels don't need Explore initialization
setIsInitialized(true);
return;
}
const initializePane = async () => {
// Use saved state if available, otherwise defaults
const savedState = exploreState;
await dispatch(
initializeExplore({
exploreId,
datasource: savedState?.datasourceUid,
queries: savedState?.queries || [],
range: savedState?.range || DEFAULT_RANGE,
eventBridge: eventBus,
compact: savedState?.compact ?? false,
})
);
setIsInitialized(true);
};
initializePane();
// Cleanup on unmount
return () => {
eventBus.removeAllListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, exploreId, eventBus, exploreState, mode]);
if (mode === 'traces-drilldown') {
return <ExploreMapTracesDrilldownPanel exploreId={exploreId} width={width} height={height} />;
}
if (mode === 'metrics-drilldown') {
return <ExploreMapMetricsDrilldownPanel exploreId={exploreId} width={width} height={height} />;
}
if (mode === 'profiles-drilldown') {
return <ExploreMapProfilesDrilldownPanel exploreId={exploreId} width={width} height={height} />;
}
if (mode === 'logs-drilldown') {
return <ExploreMapLogsDrilldownPanel exploreId={exploreId} width={width} height={height} />;
}
// Wait for Redux state to be initialized
if (!isInitialized) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Trans i18nKey="explore-map.panel.initializing">Initializing Explore...</Trans>
</div>
</div>
);
}
return (
<div
className={styles.container}
style={{
width: `${width}px`,
height: `${height}px`,
}}
>
<ExplorePaneContainer exploreId={exploreId} />
</div>
);
}, (prevProps, nextProps) => {
// Only re-render if width, height, remoteVersion, or mode changes
// This prevents re-renders when only position changes (local drag operations)
//
// NOTE: We deliberately exclude exploreState from comparison because:
// 1. It's only used during initialization (mount)
// 2. After initialization, the component manages state via Redux
// 3. exploreState is an object that gets new references even when data is the same
return (
prevProps.panelId === nextProps.panelId &&
prevProps.exploreId === nextProps.exploreId &&
prevProps.width === nextProps.width &&
prevProps.height === nextProps.height &&
prevProps.remoteVersion === nextProps.remoteVersion &&
prevProps.mode === nextProps.mode
);
});
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative',
// Override Explore styles to fit in panel
'& .explore-container': {
padding: 0,
height: '100%',
},
}),
loading: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: theme.colors.text.secondary,
fontSize: theme.typography.body.fontSize,
}),
};
};

View File

@@ -0,0 +1,479 @@
import { css, cx } from '@emotion/css';
import React, { useCallback, useRef, useState } from 'react';
import { Rnd, RndDragCallback, RndResizeCallback } from 'react-rnd';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Button, TextArea, useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import {
associatePostItWithFrame,
bringPostItNoteToFront,
disassociatePostItFromFrame,
removePostItNote,
updatePostItNoteColor,
updatePostItNotePosition,
updatePostItNoteSize,
updatePostItNoteText,
} from '../state/crdtSlice';
import { selectFrames } from '../state/selectors';
interface ExploreMapStickyNoteProps {
postIt: {
id: string;
position: { x: number; y: number; width: number; height: number; zIndex: number };
text: string;
color: string;
createdBy?: string;
frameId?: string;
frameOffsetX?: number;
frameOffsetY?: number;
};
zoom: number;
}
export const STICKY_NOTE_COLORS = [
{ name: 'yellow', value: '#f1c40f' },
{ name: 'blue', value: '#3498db' },
{ name: 'green', value: '#2ecc71' },
{ name: 'purple', value: '#9b59b6' },
{ name: 'red', value: '#e74c3c' },
];
export function ExploreMapStickyNote({ postIt, zoom }: ExploreMapStickyNoteProps) {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const rndRef = useRef<Rnd>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(postIt.text);
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
const frames = useSelector((state) => selectFrames(state.exploreMapCRDT));
const activeFrameDragInfo = useSelector((state) => state.exploreMapCRDT.local.activeFrameDrag);
const colorInfo = STICKY_NOTE_COLORS.find((c) => c.name === postIt.color) || STICKY_NOTE_COLORS.find((c) => c.name === 'blue') || STICKY_NOTE_COLORS[0];
// Calculate effective position considering frame drag
let effectiveX = postIt.position.x;
let effectiveY = postIt.position.y;
// If this sticky note's frame is being dragged, apply the frame drag offset
if (activeFrameDragInfo && postIt.frameId === activeFrameDragInfo.draggedFrameId) {
effectiveX += activeFrameDragInfo.deltaX;
effectiveY += activeFrameDragInfo.deltaY;
}
// Check if sticky note intersects with a frame (>50% overlap)
const checkFrameIntersection = useCallback(
(x: number, y: number, width: number, height: number): string | null => {
const postItArea = width * height;
for (const frame of Object.values(frames)) {
const intersectX = Math.max(0, Math.min(x + width, frame.position.x + frame.position.width) - Math.max(x, frame.position.x));
const intersectY = Math.max(0, Math.min(y + height, frame.position.y + frame.position.height) - Math.max(y, frame.position.y));
const intersectArea = intersectX * intersectY;
if (intersectArea > postItArea * 0.5) {
return frame.id;
}
}
return null;
},
[frames]
);
const handleDragStart: RndDragCallback = useCallback(
(_e, data) => {
setDragStartPos({ x: data.x, y: data.y });
dispatch(bringPostItNoteToFront({ postItId: postIt.id }));
},
[dispatch, postIt.id]
);
const handleDragStop: RndDragCallback = useCallback(
(_e, data) => {
if (dragStartPos) {
const deltaX = data.x - dragStartPos.x;
const deltaY = data.y - dragStartPos.y;
// Only update position if there was actual movement
const hasMoved = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
if (hasMoved) {
dispatch(
updatePostItNotePosition({
postItId: postIt.id,
x: data.x,
y: data.y,
})
);
// Check if sticky note should be associated with a frame
const intersectingFrameId = checkFrameIntersection(
data.x,
data.y,
postIt.position.width,
postIt.position.height
);
if (intersectingFrameId && intersectingFrameId !== postIt.frameId) {
// Sticky note moved into a frame
const frame = frames[intersectingFrameId];
const offsetX = data.x - frame.position.x;
const offsetY = data.y - frame.position.y;
dispatch(
associatePostItWithFrame({
postItId: postIt.id,
frameId: intersectingFrameId,
offsetX,
offsetY,
})
);
} else if (!intersectingFrameId && postIt.frameId) {
// Sticky note moved out of frame
dispatch(
disassociatePostItFromFrame({
postItId: postIt.id,
})
);
}
}
}
setDragStartPos(null);
},
[dispatch, postIt.id, postIt.position.width, postIt.position.height, postIt.frameId, dragStartPos, checkFrameIntersection, frames]
);
const handleResizeStop: RndResizeCallback = useCallback(
(_e, _direction, ref, _delta, position) => {
const newWidth = ref.offsetWidth;
const newHeight = ref.offsetHeight;
// Update position
dispatch(
updatePostItNotePosition({
postItId: postIt.id,
x: position.x,
y: position.y,
})
);
// Update size
dispatch(
updatePostItNoteSize({
postItId: postIt.id,
width: newWidth,
height: newHeight,
})
);
// Check if sticky note should be associated with a frame after resize
const intersectingFrameId = checkFrameIntersection(
position.x,
position.y,
newWidth,
newHeight
);
if (intersectingFrameId && intersectingFrameId !== postIt.frameId) {
// Sticky note resized into a frame
const frame = frames[intersectingFrameId];
const offsetX = position.x - frame.position.x;
const offsetY = position.y - frame.position.y;
dispatch(
associatePostItWithFrame({
postItId: postIt.id,
frameId: intersectingFrameId,
offsetX,
offsetY,
})
);
} else if (!intersectingFrameId && postIt.frameId) {
// Sticky note resized out of frame
dispatch(
disassociatePostItFromFrame({
postItId: postIt.id,
})
);
}
},
[dispatch, postIt.id, postIt.frameId, checkFrameIntersection, frames]
);
const handleDoubleClick = useCallback(() => {
setIsEditing(true);
setEditText(postIt.text);
setTimeout(() => {
textAreaRef.current?.focus();
textAreaRef.current?.select();
}, 0);
}, [postIt.text]);
const handleSaveText = useCallback(() => {
dispatch(
updatePostItNoteText({
postItId: postIt.id,
text: editText,
})
);
setIsEditing(false);
}, [dispatch, postIt.id, editText]);
const handleCancelEdit = useCallback(() => {
setEditText(postIt.text);
setIsEditing(false);
}, [postIt.text]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancelEdit();
} else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleSaveText();
}
},
[handleCancelEdit, handleSaveText]
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (window.confirm(t('explore-map.sticky.delete-confirm', 'Delete this sticky note?'))) {
dispatch(removePostItNote({ postItId: postIt.id }));
}
},
[dispatch, postIt.id]
);
const handleColorChange = useCallback(
(color: string) => {
dispatch(
updatePostItNoteColor({
postItId: postIt.id,
color,
})
);
},
[dispatch, postIt.id]
);
// Disable dragging when the frame is being dragged
const isDraggingDisabled = !!(activeFrameDragInfo && postIt.frameId === activeFrameDragInfo.draggedFrameId);
return (
<Rnd
ref={rndRef}
position={{ x: effectiveX, y: effectiveY }}
size={{ width: postIt.position.width, height: postIt.position.height }}
scale={zoom}
onDragStart={handleDragStart}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
disableDragging={isDraggingDisabled}
bounds="parent"
className={styles.postItContainer}
style={{ zIndex: postIt.position.zIndex }}
minWidth={150}
minHeight={150}
maxWidth={400}
maxHeight={400}
>
<div
className={styles.postItContent}
style={{ backgroundColor: colorInfo.value }}
onDoubleClick={handleDoubleClick}
>
{isEditing ? (
<div className={styles.editor}>
<TextArea
ref={textAreaRef}
value={editText}
onChange={(e) => setEditText(e.currentTarget.value)}
onKeyDown={handleKeyDown}
onBlur={handleSaveText}
className={styles.textArea}
rows={Math.max(3, Math.floor(postIt.position.height / 30) - 2)}
autoFocus
/>
<div className={styles.editorActions}>
<Button
variant="secondary"
size="sm"
onClick={handleCancelEdit}
icon="times"
className={styles.cancelButton}
>
{t('explore-map.sticky.cancel', 'Cancel')}
</Button>
</div>
</div>
) : (
<>
<div className={styles.textContent}>{postIt.text || t('explore-map.sticky.empty', 'Double-click to edit')}</div>
<div className={styles.actions}>
<div className={styles.colorPicker}>
{STICKY_NOTE_COLORS.map((color) => (
<button
key={color.name}
className={cx(styles.colorButton, postIt.color === color.name && styles.colorButtonActive)}
style={{ backgroundColor: color.value }}
onClick={(e) => {
e.stopPropagation();
handleColorChange(color.name);
}}
title={color.name}
aria-label={t('explore-map.sticky.color', 'Change color to {{color}}', { color: color.name })}
/>
))}
</div>
<Button
variant="secondary"
size="sm"
fill="text"
icon="trash-alt"
onClick={handleDelete}
className={styles.deleteButton}
tooltip={t('explore-map.sticky.delete', 'Delete sticky note')}
/>
</div>
</>
)}
</div>
</Rnd>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
postItContainer: css({
cursor: 'move',
'&:hover': {
boxShadow: theme.shadows.z3,
},
}),
postItContent: css({
width: '100%',
height: '100%',
padding: theme.spacing(1.5),
display: 'flex',
flexDirection: 'column',
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z2,
position: 'relative',
overflow: 'hidden',
// Add a subtle border for better definition
border: `1px solid rgba(0, 0, 0, 0.1)`,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'box-shadow 0.2s',
},
}),
textContent: css({
flex: 1,
fontSize: theme.typography.body.fontSize,
// Use darker color for better contrast on bright sticky note backgrounds
color: theme.colors.text.maxContrast,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflow: 'auto',
minHeight: '60px',
lineHeight: theme.typography.body.lineHeight,
fontFamily: theme.typography.fontFamilyMonospace,
fontWeight: theme.typography.fontWeightMedium,
// Add text shadow for better readability on bright backgrounds
textShadow: '0 1px 3px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.3)',
}),
editor: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
gap: theme.spacing(1),
}),
textArea: css({
flex: 1,
resize: 'none',
fontFamily: theme.typography.fontFamilyMonospace,
fontWeight: theme.typography.fontWeightMedium,
// Use a more opaque white background for better contrast
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: `2px solid rgba(0, 0, 0, 0.3)`,
color: theme.colors.text.maxContrast,
padding: theme.spacing(1),
borderRadius: theme.shape.radius.default,
// Ensure text is clearly visible
fontSize: theme.typography.body.fontSize,
lineHeight: theme.typography.body.lineHeight,
'&:focus': {
backgroundColor: theme.colors.background.primary,
borderColor: theme.colors.primary.border,
outline: 'none',
boxShadow: `0 0 0 2px ${theme.colors.primary.transparent}`,
},
}),
editorActions: css({
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(1),
}),
actions: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: theme.spacing(1),
gap: theme.spacing(1),
}),
colorPicker: css({
display: 'flex',
gap: theme.spacing(0.5),
}),
colorButton: css({
width: '20px',
height: '20px',
borderRadius: theme.shape.radius.default,
border: `2px solid ${theme.colors.border.weak}`,
cursor: 'pointer',
padding: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'transform 0.2s, border-color 0.2s',
},
'&:hover': {
transform: 'scale(1.1)',
borderColor: theme.colors.border.medium,
},
}),
colorButtonActive: css({
borderColor: theme.colors.text.primary,
borderWidth: '3px',
}),
deleteButton: css({
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
padding: theme.spacing(0.5, 1),
color: '#555555',
opacity: 1,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'border-color 0.2s, color 0.2s',
},
'&:hover': {
borderColor: theme.colors.border.medium,
color: '#333333',
},
}),
cancelButton: css({
backgroundColor: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'background-color 0.2s',
},
'&:hover': {
backgroundColor: theme.colors.background.canvas,
},
}),
};
};

View File

@@ -0,0 +1,362 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useRef, useState } from 'react';
import { GrafanaTheme2, TimeRange, TimeZone } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { Button, ButtonGroup, ConfirmModal, Dropdown, Input, Menu, ToolbarButton, UsersIndicator, useStyles2, TimeRangePicker } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { useTransformContext } from '../context/TransformContext';
import { useCanvasPersistence } from '../hooks/useCanvasPersistence';
import { updateMapTitle, updateGlobalTimeRange, updateAllPanelsTimeRange } from '../state/crdtSlice';
import { selectPanelCount, selectViewport, selectMapTitle, selectActiveUsers } from '../state/selectors';
interface ExploreMapToolbarProps {
uid?: string;
}
export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
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);
// Time range state - read from Redux
const [timeZone] = useState<TimeZone>('browser');
const panelCount = useSelector((state) => selectPanelCount(state.exploreMapCRDT));
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const mapTitle = useSelector((state) => selectMapTitle(state.exploreMapCRDT));
const activeUsers = useSelector((state) => selectActiveUsers(state.exploreMapCRDT));
const timeRange = useSelector((state) => state.exploreMapCRDT.local.globalTimeRange);
useEffect(() => {
if (mapTitle) {
setTitleValue(mapTitle);
}
}, [mapTitle]);
useEffect(() => {
if (editingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [editingTitle]);
const handleResetCanvas = useCallback(() => {
setShowResetConfirm(true);
}, []);
const confirmResetCanvas = useCallback(() => {
// TODO: Implement resetCanvas for CRDT state
// dispatch(resetCanvas());
console.warn('Reset canvas not yet implemented for CRDT state');
setShowResetConfirm(false);
}, []);
const handleZoomIn = useCallback(() => {
if (transformRef?.current) {
transformRef.current.zoomIn(0.2);
}
}, [transformRef]);
const handleZoomOut = useCallback(() => {
if (transformRef?.current) {
transformRef.current.zoomOut(0.2);
}
}, [transformRef]);
const handleResetZoom = useCallback(() => {
if (transformRef?.current) {
// Reset to center of canvas (5000, 5000)
// Calculate position to center the viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const panX = -(5000 - viewportWidth / 2);
const panY = -(5000 - viewportHeight / 2);
transformRef.current.setTransform(panX, panY, 1, 200);
}
}, [transformRef]);
const handleExport = useCallback(() => {
exportCanvas();
}, [exportCanvas]);
const handleImport = useCallback(() => {
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 handleTimeRangeChange = useCallback((newTimeRange: TimeRange) => {
dispatch(updateGlobalTimeRange({ timeRange: newTimeRange }));
}, [dispatch]);
const handleApplyTimeRangeToAll = useCallback(() => {
dispatch(updateAllPanelsTimeRange({ timeRange }));
}, [dispatch, timeRange]);
const getSaveStatus = () => {
if (!uid) {
return null; // No status in localStorage mode
}
if (saving) {
return <span className={styles.saveStatus}>{t('explore-map.toolbar.saving', 'Saving...')}</span>;
}
if (lastSaved) {
const secondsAgo = Math.floor((Date.now() - lastSaved.getTime()) / 1000);
if (secondsAgo < 5) {
return <span className={styles.saveStatus}>{t('explore-map.toolbar.saved', 'Saved')}</span>;
}
}
return null;
};
return (
<>
<div className={styles.toolbar}>
<div className={styles.toolbarSection}>
{uid && (
<Button
icon="arrow-left"
variant="secondary"
size="sm"
onClick={() => (window.location.href = '/atlas')}
tooltip={t('explore-map.toolbar.back', '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}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleTitleClick();
}
}}
role="button"
tabIndex={0}
>
<h2 className={styles.title}>{mapTitle || t('explore-map.toolbar.untitled', '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}>
<ButtonGroup>
<ToolbarButton
icon="search-minus"
onClick={handleZoomOut}
tooltip={t('explore-map.toolbar.zoom-out', 'Zoom out')}
/>
<ToolbarButton onClick={handleResetZoom} tooltip={t('explore-map.toolbar.reset-zoom', 'Reset zoom')}>
{Math.round(viewport.zoom * 100)}%
</ToolbarButton>
<ToolbarButton icon="search-plus" onClick={handleZoomIn} tooltip={t('explore-map.toolbar.zoom-in', 'Zoom in')} />
</ButtonGroup>
</div>
<div className={styles.toolbarSection}>
{activeUsers.length > 0 && (
<div className={styles.activeUsersContainer}>
<UsersIndicator users={activeUsers} limit={5} />
</div>
)}
<div className={styles.timePickerContainer}>
<div className={styles.timePickerWrapper}>
<TimeRangePicker
value={timeRange}
onChange={handleTimeRangeChange}
onChangeTimeZone={() => {}}
timeZone={timeZone}
onMoveBackward={() => {}}
onMoveForward={() => {}}
onZoom={() => {}}
hideText={false}
/>
</div>
<Button
size="sm"
variant="secondary"
onClick={handleApplyTimeRangeToAll}
tooltip={t('explore-map.toolbar.apply-time-to-all', 'Apply this time range to all panels')}
>
{t('explore-map.toolbar.apply-to-all', 'Apply')}
</Button>
</div>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t('explore-map.toolbar.export', 'Export canvas')}
icon="save"
onClick={handleExport}
/>
<Menu.Item
label={t('explore-map.toolbar.import', 'Import canvas')}
icon="upload"
onClick={handleImport}
/>
<Menu.Divider />
<Menu.Item
label={t('explore-map.toolbar.clear', 'Clear all panels')}
icon="trash-alt"
onClick={handleResetCanvas}
destructive
/>
</Menu>
}
>
<ToolbarButton icon="ellipsis-v" tooltip={t('explore-map.toolbar.more-actions', 'More actions')} />
</Dropdown>
</div>
</div>
<ConfirmModal
isOpen={showResetConfirm}
title={t('explore-map.toolbar.confirm-reset.title', 'Clear all panels')}
body={t(
'explore-map.toolbar.confirm-reset.body',
'Are you sure you want to clear all panels? This cannot be undone.'
)}
confirmText={t('explore-map.toolbar.confirm-reset.confirm', 'Clear')}
onConfirm={confirmResetCanvas}
onDismiss={() => setShowResetConfirm(false)}
/>
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
toolbar: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(1, 2),
backgroundColor: theme.colors.background.secondary,
borderBottom: `1px solid ${theme.colors.border.weak}`,
minHeight: '48px',
gap: theme.spacing(2),
}),
toolbarSection: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
panelCount: css({
fontSize: theme.typography.bodySmall.fontSize,
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,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
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',
}),
activeUsersContainer: css({
display: 'flex',
alignItems: 'center',
marginRight: theme.spacing(2),
}),
timePickerContainer: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
timePickerWrapper: css({
// Hide the backward, forward, and zoom buttons
'& > div > button:first-child': {
display: 'none', // Hide backward button
},
'& > div > button:nth-last-child(2)': {
display: 'none', // Hide forward button
},
'& > div > button:last-child': {
display: 'none', // Hide zoom button
},
}),
};
};

View File

@@ -0,0 +1,187 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { selectViewport, selectPanels, selectFrames } from '../state/selectors';
import { updateViewport as updateViewportCRDT } from '../state/crdtSlice';
import { useTransformContext } from '../context/TransformContext';
const CANVAS_SIZE = 50000;
const MINIMAP_SIZE = 200;
const MINIMAP_SCALE = MINIMAP_SIZE / CANVAS_SIZE;
interface MinimapProps {
containerWidth: number;
containerHeight: number;
}
export const Minimap: React.FC<MinimapProps> = ({ containerWidth, containerHeight }) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDraggingRef = useRef(false);
const viewport = useSelector((state) => selectViewport(state.exploreMapCRDT));
const panels = useSelector((state) => selectPanels(state.exploreMapCRDT));
const frames = useSelector((state) => selectFrames(state.exploreMapCRDT));
const { transformRef } = useTransformContext();
// Draw the minimap
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
// Clear canvas
ctx.clearRect(0, 0, MINIMAP_SIZE, MINIMAP_SIZE);
// Draw frames
Object.values(frames).forEach((frame) => {
const x = frame.position.x * MINIMAP_SCALE;
const y = frame.position.y * MINIMAP_SCALE;
const width = frame.position.width * MINIMAP_SCALE;
const height = frame.position.height * MINIMAP_SCALE;
ctx.fillStyle = '#6366f120'; // Light indigo with 20% opacity
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 1;
ctx.fillRect(x, y, width, height);
ctx.strokeRect(x, y, width, height);
});
// Draw panels
Object.values(panels).forEach((panel) => {
const x = panel.position.x * MINIMAP_SCALE;
const y = panel.position.y * MINIMAP_SCALE;
const width = panel.position.width * MINIMAP_SCALE;
const height = panel.position.height * MINIMAP_SCALE;
ctx.fillStyle = '#3f51b5';
ctx.strokeStyle = '#5c6bc0';
ctx.lineWidth = 0.5;
ctx.fillRect(x, y, width, height);
ctx.strokeRect(x, y, width, height);
});
// Calculate viewport rectangle in canvas coordinates
// The pan values are negative offsets in screen pixels
// To get canvas coordinates: canvasX = -panX / zoom
const viewportX = (-viewport.panX / viewport.zoom) * MINIMAP_SCALE;
const viewportY = (-viewport.panY / viewport.zoom) * MINIMAP_SCALE;
const viewportWidth = (containerWidth / viewport.zoom) * MINIMAP_SCALE;
const viewportHeight = (containerHeight / viewport.zoom) * MINIMAP_SCALE;
// Draw viewport rectangle
ctx.strokeStyle = '#00b8d4';
ctx.fillStyle = '#00b8d420';
ctx.lineWidth = 2;
ctx.fillRect(viewportX, viewportY, viewportWidth, viewportHeight);
ctx.strokeRect(viewportX, viewportY, viewportWidth, viewportHeight);
}, [viewport, panels, frames, containerWidth, containerHeight]);
// Handle minimap click to pan
const handleMinimapInteraction = useCallback(
(event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas || !transformRef?.current) {
return;
}
const rect = canvas.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const clickY = event.clientY - rect.top;
// Convert minimap coordinates to canvas coordinates
const canvasX = clickX / MINIMAP_SCALE;
const canvasY = clickY / MINIMAP_SCALE;
// Calculate the target pan to center the clicked point
const targetPanX = -(canvasX * viewport.zoom - containerWidth / 2);
const targetPanY = -(canvasY * viewport.zoom - containerHeight / 2);
// Update viewport through react-zoom-pan-pinch
transformRef.current.setTransform(targetPanX, targetPanY, viewport.zoom, 200);
// Update Redux state
dispatch(
updateViewportCRDT({
zoom: viewport.zoom,
panX: targetPanX,
panY: targetPanY,
})
);
},
[dispatch, viewport.zoom, transformRef]
);
const handleMouseDown = useCallback(
(event: React.MouseEvent<HTMLCanvasElement>) => {
isDraggingRef.current = true;
handleMinimapInteraction(event);
},
[handleMinimapInteraction]
);
const handleMouseMove = useCallback(
(event: React.MouseEvent<HTMLCanvasElement>) => {
if (isDraggingRef.current) {
handleMinimapInteraction(event);
}
},
[handleMinimapInteraction]
);
const handleMouseUp = useCallback(() => {
isDraggingRef.current = false;
}, []);
const handleMouseLeave = useCallback(() => {
isDraggingRef.current = false;
}, []);
return (
<div className={styles.container}>
<canvas
ref={canvasRef}
width={MINIMAP_SIZE}
height={MINIMAP_SIZE}
className={styles.canvas}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
/>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
position: 'fixed', // Changed from absolute to fixed to escape parent overflow
bottom: theme.spacing(2),
left: theme.spacing(2),
zIndex: 10000, // Increased z-index to be above everything
borderRadius: theme.shape.radius.default,
border: `2px solid ${theme.colors.primary.border}`, // More visible border
boxShadow: theme.shadows.z3,
backgroundColor: 'transparent',
padding: theme.spacing(1),
pointerEvents: 'auto',
backdropFilter: 'blur(8px)', // Add blur effect for better visibility
}),
canvas: css({
display: 'block',
cursor: 'pointer',
borderRadius: theme.shape.radius.default,
'&:active': {
cursor: 'grabbing',
},
}),
});

View File

@@ -0,0 +1,91 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { UserCursor as UserCursorType } from '../state/types';
interface UserCursorProps {
cursor: UserCursorType;
zoom: number;
}
export function UserCursor({ cursor, zoom }: UserCursorProps) {
const styles = useStyles2(getStyles);
return (
<div
className={styles.cursorContainer}
style={{
left: cursor.x,
top: cursor.y,
transform: `scale(${1 / zoom})`,
transformOrigin: 'top left',
}}
>
<svg
className={styles.cursorSvg}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 3L5 16L9 12L12 19L14 18L11 11L17 11L5 3Z"
fill={cursor.color}
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className={styles.label} style={{ backgroundColor: cursor.color }}>
{cursor.userName}
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
cursorContainer: css({
position: 'absolute',
pointerEvents: 'none',
zIndex: 10000,
// Smooth transition matching the update frequency (100ms)
// Using linear for more predictive movement
// Also transition transform for smooth scaling when zoom changes
// Add opacity transition for smooth fade in/out when entering/leaving view
transition: 'left 0.1s linear, top 0.1s linear, opacity 0.2s ease-out',
// Will-change hint for better performance
willChange: 'left, top, transform, opacity',
// Fade in animation
animation: 'fadeIn 0.2s ease-out',
'@keyframes fadeIn': {
from: {
opacity: 0,
},
to: {
opacity: 1,
},
},
}),
cursorSvg: css({
display: 'block',
filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))',
}),
label: css({
position: 'absolute',
left: '20px',
top: '20px',
padding: '4px 8px',
borderRadius: theme.shape.radius.default,
color: 'white',
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
whiteSpace: 'nowrap',
boxShadow: theme.shadows.z2,
}),
};
};

View File

@@ -0,0 +1,14 @@
import { createContext, useContext } from 'react';
import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
interface TransformContextType {
transformRef: React.RefObject<ReactZoomPanPinchRef> | null;
}
const TransformContext = createContext<TransformContextType>({ transformRef: null });
export const useTransformContext = () => {
return useContext(TransformContext);
};
export const TransformProvider = TransformContext.Provider;

View File

@@ -0,0 +1,158 @@
/**
* Hybrid Logical Clock (HLC) implementation
*
* Combines logical time (Lamport timestamp) with physical wall-clock time
* to provide timestamps that are:
* - Monotonically increasing
* - Causally consistent
* - Approximately synchronized with real time
*
* Used for conflict resolution in LWW-Register CRDTs.
*/
export interface HLCTimestamp {
logicalTime: number; // Lamport timestamp component
wallTime: number; // Physical clock component (milliseconds since epoch)
nodeId: string; // Unique node identifier for tie-breaking
}
export class HybridLogicalClock {
private logicalTime: number = 0;
private lastWallTime: number = 0;
private nodeId: string;
constructor(nodeId: string) {
this.nodeId = nodeId;
this.lastWallTime = Date.now();
}
/**
* Advance the clock for a local event
* Returns a new timestamp representing "now"
*/
tick(): HLCTimestamp {
const now = Date.now();
if (now > this.lastWallTime) {
// Physical clock advanced - use it
this.lastWallTime = now;
this.logicalTime = 0;
} else {
// Physical clock hasn't advanced - increment logical component
this.logicalTime++;
}
return this.clone();
}
/**
* Update clock based on received timestamp from another node
* Ensures causal consistency: if A → B, then timestamp(A) < timestamp(B)
*/
update(receivedTimestamp: HLCTimestamp): void {
const now = Date.now();
const maxWall = Math.max(this.lastWallTime, receivedTimestamp.wallTime, now);
if (maxWall === this.lastWallTime && maxWall === receivedTimestamp.wallTime) {
// Same wall time - take max logical time and increment
this.logicalTime = Math.max(this.logicalTime, receivedTimestamp.logicalTime) + 1;
} else if (maxWall === receivedTimestamp.wallTime) {
// Received timestamp has newer wall time
this.lastWallTime = maxWall;
this.logicalTime = receivedTimestamp.logicalTime + 1;
} else {
// Our wall time or physical clock is newer
this.lastWallTime = maxWall;
this.logicalTime = 0;
}
}
/**
* Get current timestamp without advancing the clock
*/
now(): HLCTimestamp {
return this.clone();
}
/**
* Create a copy of the current timestamp
*/
clone(): HLCTimestamp {
return {
logicalTime: this.logicalTime,
wallTime: this.lastWallTime,
nodeId: this.nodeId,
};
}
/**
* Get the node ID
*/
getNodeId(): string {
return this.nodeId;
}
}
/**
* Compare two HLC timestamps
* Returns:
* < 0 if a < b
* > 0 if a > b
* = 0 if a == b
*/
export function compareHLC(a: HLCTimestamp, b: HLCTimestamp): number {
// First compare wall time
if (a.wallTime !== b.wallTime) {
return a.wallTime - b.wallTime;
}
// Then compare logical time
if (a.logicalTime !== b.logicalTime) {
return a.logicalTime - b.logicalTime;
}
// Finally compare node IDs for deterministic tie-breaking
return a.nodeId.localeCompare(b.nodeId);
}
/**
* Check if timestamp a happened before timestamp b
*/
export function happensBefore(a: HLCTimestamp, b: HLCTimestamp): boolean {
return compareHLC(a, b) < 0;
}
/**
* Check if timestamp a happened after timestamp b
*/
export function happensAfter(a: HLCTimestamp, b: HLCTimestamp): boolean {
return compareHLC(a, b) > 0;
}
/**
* Check if two timestamps are equal
*/
export function timestampEquals(a: HLCTimestamp, b: HLCTimestamp): boolean {
return compareHLC(a, b) === 0;
}
/**
* Get the maximum of two timestamps
*/
export function maxTimestamp(a: HLCTimestamp, b: HLCTimestamp): HLCTimestamp {
return compareHLC(a, b) >= 0 ? a : b;
}
/**
* Serialize timestamp to JSON
*/
export function serializeHLC(timestamp: HLCTimestamp): string {
return JSON.stringify(timestamp);
}
/**
* Deserialize timestamp from JSON
*/
export function deserializeHLC(json: string): HLCTimestamp {
return JSON.parse(json);
}

View File

@@ -0,0 +1,44 @@
/**
* CRDT module exports
*
* Conflict-Free Replicated Data Types for collaborative Explore Map editing
*/
/* eslint-disable no-barrel-files/no-barrel-files */
// Core CRDT types
export { HybridLogicalClock, compareHLC, happensBefore, happensAfter, timestampEquals, maxTimestamp } from './hlc';
export type { HLCTimestamp } from './hlc';
export { ORSet } from './orset';
export type { ORSetJSON } from './orset';
export { LWWRegister, createLWWRegister } from './lwwregister';
export type { LWWRegisterJSON } from './lwwregister';
export { PNCounter } from './pncounter';
export type { PNCounterJSON } from './pncounter';
// CRDT state manager
export { CRDTStateManager } from './state';
// Types
export type {
CRDTExploreMapState,
CRDTPanelData,
CRDTExploreMapStateJSON,
CRDTOperation,
CRDTOperationType,
AddPanelOperation,
RemovePanelOperation,
UpdatePanelPositionOperation,
UpdatePanelSizeOperation,
UpdatePanelZIndexOperation,
UpdatePanelExploreStateOperation,
UpdateTitleOperation,
AddCommentOperation,
RemoveCommentOperation,
BatchOperation,
OperationResult,
CommentData,
} from './types';

View File

@@ -0,0 +1,127 @@
/**
* Last-Write-Wins Register (LWW-Register) CRDT implementation
*
* A register that stores a single value and resolves conflicts using timestamps.
* When concurrent updates occur, the one with the highest timestamp wins.
*
* Properties:
* - Deterministic conflict resolution via timestamp ordering
* - Idempotent: setting same value with same timestamp is safe
* - Commutative: can apply updates in any order, final state is consistent
*
* Used for panel properties: position, dimensions, exploreState, etc.
*/
import { HLCTimestamp, compareHLC } from './hlc';
export interface LWWRegisterJSON<T> {
value: T;
timestamp: HLCTimestamp;
}
export class LWWRegister<T> {
private value: T;
private timestamp: HLCTimestamp;
/**
* Create a new LWW-Register with an initial value and timestamp
*
* @param initialValue - The initial value
* @param initialTimestamp - The initial timestamp
*/
constructor(initialValue: T, initialTimestamp: HLCTimestamp) {
this.value = initialValue;
this.timestamp = initialTimestamp;
}
/**
* Set the register value if the new timestamp is greater than current
*
* @param value - The new value
* @param timestamp - The timestamp of this update
* @returns true if the value was updated, false if update was ignored
*/
set(value: T, timestamp: HLCTimestamp): boolean {
// Only update if new timestamp is strictly greater
if (compareHLC(timestamp, this.timestamp) > 0) {
this.value = value;
this.timestamp = timestamp;
return true;
}
return false;
}
/**
* Get the current value
*/
get(): T {
return this.value;
}
/**
* Get the current timestamp
*/
getTimestamp(): HLCTimestamp {
return this.timestamp;
}
/**
* Merge another LWW-Register into this one
* Keeps the value with the highest timestamp
*
* @param other - The register to merge
* @returns true if this register's value was updated
*/
merge(other: LWWRegister<T>): boolean {
return this.set(other.value, other.timestamp);
}
/**
* Create a copy of this register
*/
clone(): LWWRegister<T> {
return new LWWRegister(this.value, { ...this.timestamp });
}
/**
* Serialize to JSON
*/
toJSON(): LWWRegisterJSON<T> {
return {
value: this.value,
timestamp: this.timestamp,
};
}
/**
* Deserialize from JSON
*/
static fromJSON<T>(json: LWWRegisterJSON<T>): LWWRegister<T> {
return new LWWRegister(json.value, json.timestamp);
}
/**
* Get debug information
*/
debug(): {
value: T;
timestamp: HLCTimestamp;
} {
return {
value: this.value,
timestamp: this.timestamp,
};
}
}
/**
* Helper function to create a register with a zero timestamp
* Useful for initialization
*/
export function createLWWRegister<T>(value: T, nodeId: string): LWWRegister<T> {
return new LWWRegister(value, {
logicalTime: 0,
wallTime: 0,
nodeId,
});
}

View File

@@ -0,0 +1,257 @@
/**
* Observed-Remove Set (OR-Set) CRDT implementation
*
* A set that handles concurrent add/remove operations correctly.
* Each element is tagged with unique identifiers, and removes only
* affect the specific tags they observed.
*
* Properties:
* - Add-wins semantics: concurrent add/remove results in element being present
* - Idempotent operations: applying same operation multiple times is safe
* - Commutative: operations can be applied in any order
*
* Used for tracking which panels exist on the canvas.
*/
export interface ORSetJSON<T> {
adds: Record<string, string[]>; // element -> array of unique tags
removes: string[]; // array of removed tags
}
export class ORSet<T extends string = string> {
private adds: Map<T, Set<string>>; // element -> set of unique tags
private removes: Set<string>; // set of removed tags
constructor() {
this.adds = new Map();
this.removes = new Set();
}
/**
* Add an element to the set with a unique tag
* The tag should be globally unique (e.g., operation ID)
*
* @param element - The element to add
* @param tag - Unique identifier for this add operation
*/
add(element: T, tag: string): void {
if (!this.adds.has(element)) {
this.adds.set(element, new Set());
}
this.adds.get(element)!.add(tag);
}
/**
* Remove an element from the set
* Only removes the specific tags that were observed
*
* @param element - The element to remove
* @param observedTags - The tags that were observed when the remove was issued
*/
remove(element: T, observedTags: string[]): void {
for (const tag of observedTags) {
this.removes.add(tag);
}
// Clean up the element's tags
const elementTags = this.adds.get(element);
if (elementTags) {
for (const tag of observedTags) {
elementTags.delete(tag);
}
// If no tags remain, remove the element entry
if (elementTags.size === 0) {
this.adds.delete(element);
}
}
}
/**
* Check if an element is in the set
* Element is present if it has at least one non-removed tag
*
* @param element - The element to check
* @returns true if element is in the set
*/
contains(element: T): boolean {
const tags = this.adds.get(element);
if (!tags || tags.size === 0) {
return false;
}
// Element is present if it has at least one tag that hasn't been removed
for (const tag of tags) {
if (!this.removes.has(tag)) {
return true;
}
}
return false;
}
/**
* Get all tags for an element (including removed ones)
*
* @param element - The element to get tags for
* @returns Array of tags, or empty array if element not found
*/
getTags(element: T): string[] {
const tags = this.adds.get(element);
return tags ? Array.from(tags) : [];
}
/**
* Get all elements currently in the set
*
* @returns Array of elements
*/
values(): T[] {
const result: T[] = [];
for (const [element, tags] of this.adds.entries()) {
// Include element if it has at least one non-removed tag
for (const tag of tags) {
if (!this.removes.has(tag)) {
result.push(element);
break;
}
}
}
return result;
}
/**
* Get the number of elements in the set
*/
size(): number {
return this.values().length;
}
/**
* Check if the set is empty
*/
isEmpty(): boolean {
return this.size() === 0;
}
/**
* Merge another OR-Set into this one
* Takes the union of all adds and removes
*
* @param other - The OR-Set to merge
* @returns This OR-Set (for chaining)
*/
merge(other: ORSet<T>): this {
// Merge adds (union of all tags)
for (const [element, otherTags] of other.adds.entries()) {
if (!this.adds.has(element)) {
this.adds.set(element, new Set());
}
const myTags = this.adds.get(element)!;
for (const tag of otherTags) {
myTags.add(tag);
}
}
// Merge removes (union of all removed tags)
for (const tag of other.removes) {
this.removes.add(tag);
}
// Clean up elements with all tags removed
for (const [element, tags] of this.adds.entries()) {
let hasLiveTag = false;
for (const tag of tags) {
if (!this.removes.has(tag)) {
hasLiveTag = true;
break;
}
}
if (!hasLiveTag) {
this.adds.delete(element);
}
}
return this;
}
/**
* Create a copy of this OR-Set
*/
clone(): ORSet<T> {
const copy = new ORSet<T>();
// Deep copy adds
for (const [element, tags] of this.adds.entries()) {
copy.adds.set(element, new Set(tags));
}
// Deep copy removes
copy.removes = new Set(this.removes);
return copy;
}
/**
* Clear all elements from the set (for testing/reset)
*/
clear(): void {
this.adds.clear();
this.removes.clear();
}
/**
* Serialize to JSON for network transmission or storage
*/
toJSON(): ORSetJSON<T> {
const adds: Record<string, string[]> = {};
for (const [element, tags] of this.adds.entries()) {
adds[element] = Array.from(tags);
}
return {
adds,
removes: Array.from(this.removes),
};
}
/**
* Deserialize from JSON
*/
static fromJSON<T extends string = string>(json: ORSetJSON<T>): ORSet<T> {
const set = new ORSet<T>();
// Restore adds
for (const [element, tags] of Object.entries(json.adds)) {
set.adds.set(element as T, new Set(tags));
}
// Restore removes
set.removes = new Set(json.removes);
return set;
}
/**
* Get debug information about the set
*/
debug(): {
elements: T[];
totalTags: number;
removedTags: number;
rawAdds: Map<T, Set<string>>;
rawRemoves: Set<string>;
} {
let totalTags = 0;
for (const tags of this.adds.values()) {
totalTags += tags.size;
}
return {
elements: this.values(),
totalTags,
removedTags: this.removes.size,
rawAdds: this.adds,
rawRemoves: this.removes,
};
}
}

View File

@@ -0,0 +1,167 @@
/**
* Positive-Negative Counter (PN-Counter) CRDT implementation
*
* A counter that supports both increment and decrement operations.
* Each node maintains separate positive and negative counters.
*
* Properties:
* - Commutative: operations can be applied in any order
* - Idempotent: applying same operation multiple times (with dedup) is safe
* - Eventually consistent: all replicas converge to same value
*
* Used for allocating monotonically increasing z-indices.
* For our use case, we only need increments (positive counter).
*/
export interface PNCounterJSON {
increments: Record<string, number>; // nodeId -> count
decrements: Record<string, number>; // nodeId -> count
}
export class PNCounter {
private increments: Map<string, number>; // nodeId -> count
private decrements: Map<string, number>; // nodeId -> count
constructor() {
this.increments = new Map();
this.decrements = new Map();
}
/**
* Increment the counter for a specific node
*
* @param nodeId - The node performing the increment
* @param delta - The amount to increment (default: 1)
*/
increment(nodeId: string, delta: number = 1): void {
if (delta < 0) {
throw new Error('Delta must be non-negative for increment');
}
const current = this.increments.get(nodeId) || 0;
this.increments.set(nodeId, current + delta);
}
/**
* Decrement the counter for a specific node
*
* @param nodeId - The node performing the decrement
* @param delta - The amount to decrement (default: 1)
*/
decrement(nodeId: string, delta: number = 1): void {
if (delta < 0) {
throw new Error('Delta must be non-negative for decrement');
}
const current = this.decrements.get(nodeId) || 0;
this.decrements.set(nodeId, current + delta);
}
/**
* Get the current value of the counter
* Value = sum of all increments - sum of all decrements
*/
value(): number {
let sum = 0;
// Add all increments
for (const count of this.increments.values()) {
sum += count;
}
// Subtract all decrements
for (const count of this.decrements.values()) {
sum -= count;
}
return sum;
}
/**
* Get the next value and increment the counter for a node
* This is useful for allocating sequential IDs (like z-indices)
*
* @param nodeId - The node allocating the next value
* @returns The next available value
*/
next(nodeId: string): number {
const nextValue = this.value() + 1;
this.increment(nodeId, 1);
return nextValue;
}
/**
* Merge another PN-Counter into this one
* Takes the maximum value for each node's counters
*
* @param other - The counter to merge
*/
merge(other: PNCounter): this {
// Merge increments (take max for each node)
for (const [nodeId, count] of other.increments.entries()) {
const current = this.increments.get(nodeId) || 0;
this.increments.set(nodeId, Math.max(current, count));
}
// Merge decrements (take max for each node)
for (const [nodeId, count] of other.decrements.entries()) {
const current = this.decrements.get(nodeId) || 0;
this.decrements.set(nodeId, Math.max(current, count));
}
return this;
}
/**
* Create a copy of this counter
*/
clone(): PNCounter {
const copy = new PNCounter();
copy.increments = new Map(this.increments);
copy.decrements = new Map(this.decrements);
return copy;
}
/**
* Reset the counter to zero (for testing)
*/
reset(): void {
this.increments.clear();
this.decrements.clear();
}
/**
* Serialize to JSON
*/
toJSON(): PNCounterJSON {
return {
increments: Object.fromEntries(this.increments),
decrements: Object.fromEntries(this.decrements),
};
}
/**
* Deserialize from JSON
*/
static fromJSON(json: PNCounterJSON): PNCounter {
const counter = new PNCounter();
counter.increments = new Map(Object.entries(json.increments));
counter.decrements = new Map(Object.entries(json.decrements));
return counter;
}
/**
* Get debug information
*/
debug(): {
value: number;
increments: Record<string, number>;
decrements: Record<string, number>;
nodeCount: number;
} {
return {
value: this.value(),
increments: Object.fromEntries(this.increments),
decrements: Object.fromEntries(this.decrements),
nodeCount: new Set([...this.increments.keys(), ...this.decrements.keys()]).size,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,682 @@
/**
* CRDT-based Explore Map state types
*
* This file defines the CRDT-enhanced data structures for the Explore Map
* feature, enabling conflict-free collaborative editing.
*/
import { SerializedExploreState } from '../state/types';
import { HLCTimestamp } from './hlc';
import { LWWRegister } from './lwwregister';
import { ORSet } from './orset';
import { PNCounter } from './pncounter';
/**
* CRDT state for a single panel
*/
export interface CRDTPanelData {
// Stable identifiers
id: string;
exploreId: string;
// CRDT-replicated position properties
positionX: LWWRegister<number>;
positionY: LWWRegister<number>;
width: LWWRegister<number>;
height: LWWRegister<number>;
zIndex: LWWRegister<number>;
// CRDT-replicated explore state
exploreState: LWWRegister<SerializedExploreState | undefined>;
// Panel mode (explore, traces-drilldown, metrics-drilldown, profiles-drilldown, or logs-drilldown)
mode: LWWRegister<'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown'>;
// Iframe URL for traces-drilldown panels
iframeUrl: LWWRegister<string | undefined>;
// Creator metadata (username of who created the panel)
createdBy: LWWRegister<string | undefined>;
// Frame association properties
frameId: LWWRegister<string | undefined>; // Parent frame ID
frameOffsetX: LWWRegister<number | undefined>; // Offset from frame origin
frameOffsetY: LWWRegister<number | undefined>; // Offset from frame origin
// Local counter incremented only for remote explore state updates
remoteVersion: number;
}
/**
* CRDT state for a single frame
*/
export interface CRDTFrameData {
// Stable identifier
id: string;
// CRDT-replicated properties
title: LWWRegister<string>;
positionX: LWWRegister<number>;
positionY: LWWRegister<number>;
width: LWWRegister<number>;
height: LWWRegister<number>;
zIndex: LWWRegister<number>;
color: LWWRegister<string | undefined>;
emoji: LWWRegister<string | undefined>;
// Creator metadata (username of who created the frame)
createdBy: LWWRegister<string | undefined>;
// Local counter incremented only for remote title updates
remoteVersion: number;
}
/**
* Complete CRDT-based Explore Map state
*/
export interface CommentData {
text: string;
username: string;
timestamp: number; // Unix timestamp in milliseconds
}
/**
* CRDT state for a single post-it note
*/
export interface CRDTPostItNoteData {
// Stable identifier
id: string;
// CRDT-replicated position properties
positionX: LWWRegister<number>;
positionY: LWWRegister<number>;
width: LWWRegister<number>;
height: LWWRegister<number>;
zIndex: LWWRegister<number>;
// CRDT-replicated content
text: LWWRegister<string>;
color: LWWRegister<string>; // Color theme (e.g., 'yellow', 'pink', 'blue', 'green')
// Creator metadata
createdBy: LWWRegister<string | undefined>;
// Frame association properties
frameId: LWWRegister<string | undefined>;
frameOffsetX: LWWRegister<number | undefined>;
frameOffsetY: LWWRegister<number | undefined>;
}
export interface CRDTExploreMapState {
// Map metadata
uid?: string;
title: LWWRegister<string>;
// Comment collection (OR-Set for add/remove operations)
comments: ORSet<string>; // Set of comment IDs
// Comment data (text, username, timestamp)
commentData: Map<string, CommentData>;
// Post-it note collection (OR-Set for add/remove operations)
postItNotes: ORSet<string>; // Set of post-it note IDs
// Post-it note data (position, size, content, color)
postItNoteData: Map<string, CRDTPostItNoteData>;
// Panel collection (OR-Set for add/remove operations)
panels: ORSet<string>; // Set of panel IDs
// Panel data (position, size, content)
panelData: Map<string, CRDTPanelData>;
// Frame collection (OR-Set for add/remove operations)
frames: ORSet<string>; // Set of frame IDs
// Frame data (position, size, title)
frameData: Map<string, CRDTFrameData>;
// Counter for allocating z-indices
zIndexCounter: PNCounter;
// Local-only state (not replicated via CRDT)
local: {
viewport: {
zoom: number;
panX: number;
panY: number;
};
selectedPanelIds: string[];
cursors: Record<string, {
userId: string;
userName: string;
color: string;
x: number;
y: number;
lastUpdated: number;
}>;
};
}
/**
* JSON-serializable version of CRDT state
*/
export interface CRDTExploreMapStateJSON {
uid?: string;
title: {
value: string;
timestamp: HLCTimestamp;
};
comments?: {
adds: Record<string, string[]>;
removes: string[];
};
commentData?: Record<string, CommentData>;
postItNotes?: {
adds: Record<string, string[]>;
removes: string[];
};
postItNoteData?: Record<string, {
id: string;
positionX: { value: number; timestamp: HLCTimestamp };
positionY: { value: number; timestamp: HLCTimestamp };
width: { value: number; timestamp: HLCTimestamp };
height: { value: number; timestamp: HLCTimestamp };
zIndex: { value: number; timestamp: HLCTimestamp };
text: { value: string; timestamp: HLCTimestamp };
color: { value: string; timestamp: HLCTimestamp };
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
frameId?: { value: string | undefined; timestamp: HLCTimestamp };
frameOffsetX?: { value: number | undefined; timestamp: HLCTimestamp };
frameOffsetY?: { value: number | undefined; timestamp: HLCTimestamp };
}>;
panels: {
adds: Record<string, string[]>;
removes: string[];
};
panelData: Record<string, {
id: string;
exploreId: string;
positionX: { value: number; timestamp: HLCTimestamp };
positionY: { value: number; timestamp: HLCTimestamp };
width: { value: number; timestamp: HLCTimestamp };
height: { value: number; timestamp: HLCTimestamp };
zIndex: { value: number; timestamp: HLCTimestamp };
exploreState: { value: SerializedExploreState | undefined; timestamp: HLCTimestamp };
mode: { value: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown'; timestamp: HLCTimestamp };
iframeUrl: { value: string | undefined; timestamp: HLCTimestamp };
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
frameId?: { value: string | undefined; timestamp: HLCTimestamp };
frameOffsetX?: { value: number | undefined; timestamp: HLCTimestamp };
frameOffsetY?: { value: number | undefined; timestamp: HLCTimestamp };
remoteVersion?: number;
}>;
frames?: {
adds: Record<string, string[]>;
removes: string[];
};
frameData?: Record<string, {
id: string;
title: { value: string; timestamp: HLCTimestamp };
positionX: { value: number; timestamp: HLCTimestamp };
positionY: { value: number; timestamp: HLCTimestamp };
width: { value: number; timestamp: HLCTimestamp };
height: { value: number; timestamp: HLCTimestamp };
zIndex: { value: number; timestamp: HLCTimestamp };
color?: { value: string | undefined; timestamp: HLCTimestamp };
emoji?: { value: string | undefined; timestamp: HLCTimestamp };
createdBy?: { value: string | undefined; timestamp: HLCTimestamp };
remoteVersion?: number;
}>;
zIndexCounter: {
increments: Record<string, number>;
decrements: Record<string, number>;
};
}
/**
* Operation types for CRDT updates
*/
export type CRDTOperationType =
| 'add-panel'
| 'remove-panel'
| 'update-panel-position'
| 'update-panel-size'
| 'update-panel-zindex'
| 'update-panel-explore-state'
| 'update-panel-iframe-url'
| 'update-title'
| 'add-comment'
| 'remove-comment'
| 'add-frame'
| 'remove-frame'
| 'update-frame-position'
| 'update-frame-size'
| 'update-frame-title'
| 'update-frame-color'
| 'update-frame-emoji'
| 'associate-panel-with-frame'
| 'disassociate-panel-from-frame'
| 'add-postit'
| 'remove-postit'
| 'update-postit-position'
| 'update-postit-size'
| 'update-postit-zindex'
| 'update-postit-text'
| 'update-postit-color'
| 'associate-postit-with-frame'
| 'disassociate-postit-from-frame'
| 'batch'; // For batching multiple operations
/**
* Base operation interface
*/
export interface CRDTOperationBase {
type: CRDTOperationType;
mapUid: string;
operationId: string; // Unique operation ID (UUID)
timestamp: HLCTimestamp;
nodeId: string; // Client/user ID
}
/**
* Add panel operation
*/
export interface AddPanelOperation extends CRDTOperationBase {
type: 'add-panel';
payload: {
panelId: string;
exploreId: string;
position: {
x: number;
y: number;
width: number;
height: number;
};
mode?: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown';
createdBy?: string;
initialExploreState?: SerializedExploreState; // Optional initial datasource/query configuration
};
}
/**
* Remove panel operation
*/
export interface RemovePanelOperation extends CRDTOperationBase {
type: 'remove-panel';
payload: {
panelId: string;
observedTags: string[]; // Tags from OR-Set
};
}
/**
* Update panel position operation
*/
export interface UpdatePanelPositionOperation extends CRDTOperationBase {
type: 'update-panel-position';
payload: {
panelId: string;
x: number;
y: number;
};
}
/**
* Update panel size operation
*/
export interface UpdatePanelSizeOperation extends CRDTOperationBase {
type: 'update-panel-size';
payload: {
panelId: string;
width: number;
height: number;
};
}
/**
* Update panel z-index operation
*/
export interface UpdatePanelZIndexOperation extends CRDTOperationBase {
type: 'update-panel-zindex';
payload: {
panelId: string;
zIndex: number;
};
}
/**
* Update panel explore state operation
*/
export interface UpdatePanelExploreStateOperation extends CRDTOperationBase {
type: 'update-panel-explore-state';
payload: {
panelId: string;
exploreState: SerializedExploreState | undefined;
forceReload?: boolean; // Force panel reload even for local operations
};
}
/**
* Update panel iframe URL operation
*/
export interface UpdatePanelIframeUrlOperation extends CRDTOperationBase {
type: 'update-panel-iframe-url';
payload: {
panelId: string;
iframeUrl: string | undefined;
forceReload?: boolean; // Force panel reload even for local operations
};
}
/**
* Update map title operation
*/
export interface UpdateTitleOperation extends CRDTOperationBase {
type: 'update-title';
payload: {
title: string;
};
}
/**
* Add comment operation
*/
export interface AddCommentOperation extends CRDTOperationBase {
type: 'add-comment';
payload: {
commentId: string;
comment: CommentData;
};
}
/**
* Remove comment operation
*/
export interface RemoveCommentOperation extends CRDTOperationBase {
type: 'remove-comment';
payload: {
commentId: string;
observedTags: string[]; // Tags from OR-Set
};
}
/**
* Add frame operation
*/
export interface AddFrameOperation extends CRDTOperationBase {
type: 'add-frame';
payload: {
frameId: string;
title: string;
position: {
x: number;
y: number;
width: number;
height: number;
};
createdBy?: string;
color?: string;
emoji?: string;
};
}
/**
* Remove frame operation
*/
export interface RemoveFrameOperation extends CRDTOperationBase {
type: 'remove-frame';
payload: {
frameId: string;
observedTags: string[]; // Tags from OR-Set
};
}
/**
* Update frame position operation
*/
export interface UpdateFramePositionOperation extends CRDTOperationBase {
type: 'update-frame-position';
payload: {
frameId: string;
x: number;
y: number;
deltaX: number; // For batch-updating child panels
deltaY: number;
};
}
/**
* Update frame size operation
*/
export interface UpdateFrameSizeOperation extends CRDTOperationBase {
type: 'update-frame-size';
payload: {
frameId: string;
width: number;
height: number;
};
}
/**
* Update frame title operation
*/
export interface UpdateFrameTitleOperation extends CRDTOperationBase {
type: 'update-frame-title';
payload: {
frameId: string;
title: string;
};
}
/**
* Update frame color operation
*/
export interface UpdateFrameColorOperation extends CRDTOperationBase {
type: 'update-frame-color';
payload: {
frameId: string;
color: string | undefined;
};
}
/**
* Update frame emoji operation
*/
export interface UpdateFrameEmojiOperation extends CRDTOperationBase {
type: 'update-frame-emoji';
payload: {
frameId: string;
emoji: string | undefined;
};
}
/**
* Associate panel with frame operation
*/
export interface AssociatePanelWithFrameOperation extends CRDTOperationBase {
type: 'associate-panel-with-frame';
payload: {
panelId: string;
frameId: string;
offsetX: number; // Relative to frame's top-left
offsetY: number;
};
}
/**
* Disassociate panel from frame operation
*/
export interface DisassociatePanelFromFrameOperation extends CRDTOperationBase {
type: 'disassociate-panel-from-frame';
payload: {
panelId: string;
};
}
/**
* Add post-it note operation
*/
export interface AddPostItOperation extends CRDTOperationBase {
type: 'add-postit';
payload: {
postItId: string;
position: {
x: number;
y: number;
width: number;
height: number;
};
text?: string;
color?: string;
createdBy?: string;
};
}
/**
* Remove post-it note operation
*/
export interface RemovePostItOperation extends CRDTOperationBase {
type: 'remove-postit';
payload: {
postItId: string;
observedTags: string[]; // Tags from OR-Set
};
}
/**
* Update post-it note position operation
*/
export interface UpdatePostItPositionOperation extends CRDTOperationBase {
type: 'update-postit-position';
payload: {
postItId: string;
x: number;
y: number;
};
}
/**
* Update post-it note size operation
*/
export interface UpdatePostItSizeOperation extends CRDTOperationBase {
type: 'update-postit-size';
payload: {
postItId: string;
width: number;
height: number;
};
}
/**
* Update post-it note z-index operation
*/
export interface UpdatePostItZIndexOperation extends CRDTOperationBase {
type: 'update-postit-zindex';
payload: {
postItId: string;
zIndex: number;
};
}
/**
* Update post-it note text operation
*/
export interface UpdatePostItTextOperation extends CRDTOperationBase {
type: 'update-postit-text';
payload: {
postItId: string;
text: string;
};
}
/**
* Update post-it note color operation
*/
export interface UpdatePostItColorOperation extends CRDTOperationBase {
type: 'update-postit-color';
payload: {
postItId: string;
color: string;
};
}
/**
* Associate post-it note with frame operation
*/
export interface AssociatePostItWithFrameOperation extends CRDTOperationBase {
type: 'associate-postit-with-frame';
payload: {
postItId: string;
frameId: string;
offsetX: number;
offsetY: number;
};
}
/**
* Disassociate post-it note from frame operation
*/
export interface DisassociatePostItFromFrameOperation extends CRDTOperationBase {
type: 'disassociate-postit-from-frame';
payload: {
postItId: string;
};
}
/**
* Batch operation (multiple operations in one)
*/
export interface BatchOperation extends CRDTOperationBase {
type: 'batch';
payload: {
operations: CRDTOperation[];
};
}
/**
* Union type of all operations
*/
export type CRDTOperation =
| AddPanelOperation
| RemovePanelOperation
| UpdatePanelPositionOperation
| UpdatePanelSizeOperation
| UpdatePanelZIndexOperation
| UpdatePanelExploreStateOperation
| UpdatePanelIframeUrlOperation
| UpdateTitleOperation
| AddCommentOperation
| RemoveCommentOperation
| AddFrameOperation
| RemoveFrameOperation
| UpdateFramePositionOperation
| UpdateFrameSizeOperation
| UpdateFrameTitleOperation
| UpdateFrameColorOperation
| UpdateFrameEmojiOperation
| AssociatePanelWithFrameOperation
| DisassociatePanelFromFrameOperation
| AddPostItOperation
| RemovePostItOperation
| UpdatePostItPositionOperation
| UpdatePostItSizeOperation
| UpdatePostItZIndexOperation
| UpdatePostItTextOperation
| UpdatePostItColorOperation
| AssociatePostItWithFrameOperation
| DisassociatePostItFromFrameOperation
| BatchOperation;
/**
* Result of applying an operation
*/
export interface OperationResult {
success: boolean;
applied: boolean; // Whether the operation made changes
error?: string;
}

View File

@@ -0,0 +1,373 @@
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 { initializeFromLegacyState, loadState as loadCRDTState } from '../state/crdtSlice';
import { loadCanvas } from '../state/exploreMapSlice';
import { selectPanels, selectFrames, selectMapTitle, selectViewport } from '../state/selectors';
import { ExploreMapState, initialExploreMapState, SerializedExploreState } from '../state/types';
const STORAGE_KEY = 'grafana.exploreMap.state';
const AUTO_SAVE_DELAY_MS = 2000;
const MAX_SAVE_DELAY_MS = 5000; // Maximum time to wait before forcing a save
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 crdtState = useSelector((state) => state.exploreMapCRDT);
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);
const lastSavedCRDTStateRef = useRef<string | null | undefined>(null);
const firstChangeTimeRef = useRef<number | null>(null);
// 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(() => {
if (initialLoadDone.current) {
return;
}
const loadState = async () => {
if (uid) {
// Load from API
try {
setLoading(true);
const mapData = await exploreMapApi.getExploreMap(uid);
// Handle empty or missing data (new maps)
let parsed: ExploreMapState;
if (!mapData.data || mapData.data.trim() === '') {
// Initialize with default empty state for new maps
parsed = {
...initialExploreMapState,
uid: mapData.uid,
title: mapData.title,
};
} else {
parsed = JSON.parse(mapData.data);
// Use title from DB column, not from JSON data
parsed.uid = mapData.uid;
parsed.title = mapData.title;
}
// Load into legacy state (for backward compatibility)
dispatch(loadCanvas(parsed));
// Initialize CRDT state from loaded data
// If CRDT state is available, use it directly. Otherwise, initialize from legacy panels.
if (parsed.crdtState) {
// Load the saved CRDT state which includes proper OR-Set metadata
dispatch(loadCRDTState({ crdtState: parsed.crdtState }));
// Store the loaded state as the last saved state to prevent immediate re-save
lastSavedCRDTStateRef.current = JSON.stringify(parsed.crdtState);
} else {
// Fallback to legacy initialization for backward compatibility
dispatch(initializeFromLegacyState({
uid: parsed.uid,
title: parsed.title,
panels: parsed.panels || {},
viewport: parsed.viewport || initialExploreMapState.viewport,
}));
}
} catch (error) {
console.error('Failed to load map from API:', error);
dispatch(
notifyApp(
createErrorNotification('Failed to load atlas', 'The map may not exist or you may not have access')
)
);
// Redirect to list page after a delay
setTimeout(() => {
window.location.href = '/atlas';
}, 2000);
} finally {
setLoading(false);
// Mark initial load as done AFTER loading completes
initialLoadDone.current = true;
}
} else {
// Load from localStorage (legacy mode)
try {
const savedState = store.get(STORAGE_KEY);
if (savedState) {
const parsed: ExploreMapState = JSON.parse(savedState);
// Load into legacy state
dispatch(loadCanvas(parsed));
// Initialize CRDT state from loaded data
// If CRDT state is available, use it directly. Otherwise, initialize from legacy panels.
if (parsed.crdtState) {
// Load the saved CRDT state which includes proper OR-Set metadata
dispatch(loadCRDTState({ crdtState: parsed.crdtState }));
// Store the loaded state as the last saved state to prevent immediate re-save
lastSavedCRDTStateRef.current = JSON.stringify(parsed.crdtState);
} else {
// Fallback to legacy initialization for backward compatibility
dispatch(initializeFromLegacyState({
uid: parsed.uid,
title: parsed.title,
panels: parsed.panels,
viewport: parsed.viewport,
}));
}
}
} catch (error) {
console.error('Failed to load canvas state from storage:', error);
}
// Mark initial load as done immediately for localStorage (no async delay)
initialLoadDone.current = true;
}
};
loadState();
}, [dispatch, uid]);
// Auto-save to API or localStorage
useEffect(() => {
// Skip auto-save during initial load
if (!initialLoadDone.current || loading) {
return;
}
// Get current CRDT state as panels
const panels = selectPanels(crdtState);
const frames = selectFrames(crdtState);
const mapTitle = selectMapTitle(crdtState);
const viewport = selectViewport(crdtState);
// 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.
// Allow saving if there are either panels or frames
if (Object.keys(panels || {}).length === 0 && Object.keys(frames || {}).length === 0) {
return;
}
// Check if CRDT state has actually changed (ignore local UI state like selection)
const currentCRDTStateStr = crdtState.crdtStateJSON;
if (currentCRDTStateStr === lastSavedCRDTStateRef.current) {
// No changes to persist
return;
}
// Track when the first change happened for max-wait enforcement
const now = Date.now();
if (firstChangeTimeRef.current === null) {
firstChangeTimeRef.current = now;
}
// Clear any pending save timeout before scheduling a new one
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
// Check if we've exceeded the maximum wait time
const timeSinceFirstChange = now - (firstChangeTimeRef.current || now);
const shouldForceImmediateSave = timeSinceFirstChange >= MAX_SAVE_DELAY_MS;
const saveState = async () => {
// CRDT panels already contain exploreState from savePanelExploreState actions
// We don't need to enrich them with live Explore pane data
// Save both legacy format (for backward compat) and CRDT state
const enrichedState: ExploreMapState = {
uid,
title: mapTitle,
viewport,
panels: panels, // Already contains exploreState from CRDT
frames: frames, // Frames from CRDT state
selectedPanelIds: [],
nextZIndex: 1,
cursors: {},
cursorMode: 'pointer',
// Store the raw CRDT state for proper sync across sessions
crdtState: crdtState.crdtStateJSON ? JSON.parse(crdtState.crdtStateJSON) : undefined,
};
const performSave = async () => {
try {
setSaving(true);
const titleToSave = mapTitle || 'Untitled Map';
const dataToSave = enrichedState;
await exploreMapApi.updateExploreMap(uid!, {
title: titleToSave,
data: dataToSave,
});
setLastSaved(new Date());
// Update last saved state ref to prevent duplicate saves
lastSavedCRDTStateRef.current = currentCRDTStateStr;
// Reset the first change timer after successful save
firstChangeTimeRef.current = null;
} catch (error) {
console.error('Failed to save atlas:', error);
dispatch(notifyApp(createErrorNotification('Failed to save atlas', 'Changes may not be persisted')));
} finally {
setSaving(false);
}
};
if (uid) {
// Save to API with debounce
if (shouldForceImmediateSave) {
// Force immediate save if max wait time exceeded
performSave();
} else {
saveTimeoutRef.current = setTimeout(performSave, AUTO_SAVE_DELAY_MS);
}
} else {
// Save to localStorage immediately (legacy mode)
try {
store.set(STORAGE_KEY, JSON.stringify(enrichedState));
// Update last saved state ref to prevent duplicate saves
lastSavedCRDTStateRef.current = currentCRDTStateStr;
// Reset the first change timer after successful save
firstChangeTimeRef.current = null;
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
}
};
saveState();
}, [crdtState, dispatch, loading, uid]);
const exportCanvas = useCallback(() => {
try {
// Enrich with Explore state before exporting
const enrichedState = enrichStateWithExploreData({
...exploreMapState,
viewport: initialExploreMapState.viewport, // Don't export viewport state - use initial centered viewport
});
const dataStr = JSON.stringify(enrichedState, null, 2);
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
const exportFileDefaultName = `explore-map-${new Date().toISOString()}.json`;
const linkElement = document.createElement('a');
linkElement.setAttribute('href', dataUri);
linkElement.setAttribute('download', exportFileDefaultName);
linkElement.click();
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, enrichStateWithExploreData, exploreMapState]);
const importCanvas = useCallback(() => {
try {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (e: Event) => {
if (!(e.target instanceof HTMLInputElement)) {
return;
}
const file = e.target.files?.[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (event) => {
try {
const result = event.target?.result;
if (typeof result !== 'string') {
throw new Error('Invalid file content');
}
const parsed: ExploreMapState = JSON.parse(result);
// Load into legacy state
dispatch(loadCanvas(parsed));
// Initialize CRDT state from imported data
// If CRDT state is available, use it directly. Otherwise, initialize from legacy panels.
if (parsed.crdtState) {
// Load the saved CRDT state which includes proper OR-Set metadata
dispatch(loadCRDTState({ crdtState: parsed.crdtState }));
} else {
// Fallback to legacy initialization for backward compatibility
dispatch(initializeFromLegacyState({
uid: parsed.uid,
title: parsed.title,
panels: parsed.panels,
viewport: parsed.viewport,
}));
}
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')));
}
};
reader.readAsText(file);
};
input.click();
} catch (error) {
console.error('Failed to import canvas:', error);
dispatch(notifyApp(createErrorNotification('Failed to import canvas', 'Check console for details')));
}
}, [dispatch]);
return {
loading,
saving,
lastSaved,
exportCanvas,
importCanvas,
};
}

View File

@@ -0,0 +1,274 @@
/**
* React hook for real-time cursor synchronization via Grafana Live
*
* This hook handles sending and receiving cursor position updates
* for collaborative cursor sharing across all active sessions.
*/
import { throttle } from 'lodash';
import { useCallback, useEffect, useRef } from 'react';
import { Unsubscribable } from 'rxjs';
import { LiveChannelAddress, LiveChannelScope, isLiveChannelMessageEvent } from '@grafana/data';
import { getGrafanaLiveSrv } from '@grafana/runtime';
import { StoreState, useDispatch, useSelector } from 'app/types/store';
import { updateCursor, removeCursor } from '../state/crdtSlice';
import { selectSessionId } from '../state/selectors';
import { UserCursor } from '../state/types';
export interface CursorSyncOptions {
mapUid: string;
enabled?: boolean;
throttleMs?: number;
}
interface CursorUpdateMessage {
type: 'cursor_update';
sessionId: string;
userId: string;
userName: string;
data: {
x: number;
y: number;
color: string;
selectedPanelIds: string[];
};
timestamp: number;
}
interface CursorLeaveMessage {
type: 'cursor_leave';
sessionId: string;
userId: string;
userName: string;
timestamp: number;
}
type CursorMessage = CursorUpdateMessage | CursorLeaveMessage;
/**
* Hook to synchronize cursor positions with Grafana Live
*/
export function useCursorSync(options: CursorSyncOptions) {
const { mapUid, enabled = true, throttleMs = 50 } = options;
const dispatch = useDispatch();
const sessionId = useSelector((state: StoreState) => selectSessionId(state.exploreMapCRDT));
const subscriptionRef = useRef<Unsubscribable | null>(null);
const channelAddressRef = useRef<LiveChannelAddress | null>(null);
const cursorColorRef = useRef<string>(generateRandomColor());
// Send cursor leave message
const sendCursorLeave = useCallback(() => {
if (!channelAddressRef.current) {
return;
}
const liveService = getGrafanaLiveSrv();
if (!liveService) {
return;
}
const message: CursorLeaveMessage = {
type: 'cursor_leave',
sessionId,
userId: '', // Will be enriched by backend
userName: '', // Will be enriched by backend
timestamp: Date.now(),
};
liveService.publish(channelAddressRef.current, message, { useSocket: true }).catch(() => {
// Failed to send cursor leave - ignore silently
});
}, [sessionId]);
// Initialize channel connection
useEffect(() => {
if (!enabled || !mapUid) {
return;
}
let isSubscribed = true;
const connect = () => {
try {
const liveService = getGrafanaLiveSrv();
if (!liveService) {
return;
}
// Create channel address for explore-map
const channelAddress: LiveChannelAddress = {
scope: LiveChannelScope.Grafana,
namespace: 'explore-map',
path: mapUid,
};
channelAddressRef.current = channelAddress;
// Subscribe to the channel stream
const subscription = liveService.getStream<CursorMessage>(channelAddress).subscribe({
next: (event) => {
if (!isSubscribed) {
return;
}
try {
// Handle cursor message events
if (isLiveChannelMessageEvent(event)) {
const message: CursorMessage = event.message;
// Skip our own messages (backend should filter, but double-check)
if (message.sessionId === sessionId) {
return;
}
if (message.type === 'cursor_update') {
const cursor: UserCursor = {
userId: message.userId,
sessionId: message.sessionId,
userName: message.userName,
color: message.data.color,
x: message.data.x,
y: message.data.y,
lastUpdated: message.timestamp,
selectedPanelIds: message.data.selectedPanelIds || [],
};
dispatch(updateCursor(cursor));
} else if (message.type === 'cursor_leave') {
dispatch(removeCursor({ sessionId: message.sessionId }));
}
}
} catch (error) {
// Silently ignore parsing errors
}
},
error: () => {
// Channel error - connection will be retried automatically
},
});
subscriptionRef.current = subscription;
} catch (error) {
// Failed to connect - will retry on next mount
}
};
connect();
return () => {
isSubscribed = false;
// Send cursor leave message before disconnecting
if (channelAddressRef.current) {
sendCursorLeave();
}
if (subscriptionRef.current) {
subscriptionRef.current.unsubscribe();
subscriptionRef.current = null;
}
channelAddressRef.current = null;
};
}, [mapUid, enabled, sessionId, dispatch, sendCursorLeave]);
// Throttled cursor update function
const sendCursorUpdate = useRef(
throttle((x: number, y: number, selectedPanelIds: string[]) => {
if (!channelAddressRef.current) {
return;
}
const liveService = getGrafanaLiveSrv();
if (!liveService) {
return;
}
const message: CursorUpdateMessage = {
type: 'cursor_update',
sessionId,
userId: '', // Will be enriched by backend
userName: '', // Will be enriched by backend
data: {
x,
y,
color: cursorColorRef.current,
selectedPanelIds,
},
timestamp: Date.now(),
};
liveService.publish(channelAddressRef.current, message, { useSocket: true }).catch(() => {
// Failed to send cursor update - ignore silently
});
}, throttleMs)
).current;
// Update cursor position (throttled)
const updatePosition = useCallback(
(x: number, y: number, selectedPanelIds: string[]) => {
sendCursorUpdate(x, y, selectedPanelIds);
},
[sendCursorUpdate]
);
// Force send cursor update immediately (not throttled) - used for selection changes
const updatePositionImmediate = useCallback(
(x: number, y: number, selectedPanelIds: string[]) => {
if (!channelAddressRef.current) {
return;
}
const liveService = getGrafanaLiveSrv();
if (!liveService) {
return;
}
const message: CursorUpdateMessage = {
type: 'cursor_update',
sessionId,
userId: '', // Will be enriched by backend
userName: '', // Will be enriched by backend
data: {
x,
y,
color: cursorColorRef.current,
selectedPanelIds,
},
timestamp: Date.now(),
};
liveService.publish(channelAddressRef.current, message, { useSocket: true }).catch(() => {
// Failed to send cursor update - ignore silently
});
},
[sessionId]
);
return {
updatePosition,
updatePositionImmediate,
color: cursorColorRef.current,
};
}
/**
* Generate a random color for cursor
*/
function generateRandomColor(): string {
const colors = [
'#FF6B6B', // Red
'#4ECDC4', // Teal
'#45B7D1', // Blue
'#FFA07A', // Orange
'#98D8C8', // Mint
'#F7DC6F', // Yellow
'#BB8FCE', // Purple
'#85C1E2', // Sky Blue
'#F8B739', // Amber
'#52C1B9', // Turquoise
];
return colors[Math.floor(Math.random() * colors.length)];
}

View File

@@ -0,0 +1,177 @@
/**
* Hook to track cursor positions relative to the viewport
*
* Determines if cursors are visible in the current viewport and calculates
* edge positions for off-screen cursors to show indicators at the viewport edge.
*/
import { useMemo } from 'react';
import { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
import { UserCursor } from '../state/types';
import { CanvasViewport } from '../state/types';
export interface CursorViewportInfo {
cursor: UserCursor;
isVisible: boolean;
edgePosition?: {
x: number; // Screen pixel position on the edge
y: number; // Screen pixel position on the edge
side: 'top' | 'bottom' | 'left' | 'right'; // Which edge
distance: number; // Distance from viewport in canvas units
};
}
interface UseCursorViewportTrackingOptions {
cursors: Record<string, UserCursor>;
viewport: CanvasViewport;
transformRef: React.RefObject<ReactZoomPanPinchRef> | null;
containerWidth: number;
containerHeight: number;
}
const CANVAS_SIZE = 50000;
const EDGE_INDICATOR_MARGIN = 20; // Pixels from edge
/**
* Calculate visible bounds of the viewport in canvas coordinates
*/
function getVisibleBounds(
viewport: CanvasViewport,
transformRef: React.RefObject<ReactZoomPanPinchRef> | null,
containerWidth: number,
containerHeight: number
) {
// Get current transform state
const scale = transformRef?.current?.state?.scale ?? viewport.zoom;
const panX = transformRef?.current?.state?.positionX ?? viewport.panX;
const panY = transformRef?.current?.state?.positionY ?? viewport.panY;
// The pan values are in screen pixels and represent how much the canvas is shifted
// Negative panX means the canvas is shifted left (showing more right content)
// To convert to canvas coordinates, we need to:
// 1. Negate the pan to get the offset
// 2. Divide by scale to get canvas units
const visibleLeft = -panX / scale;
const visibleTop = -panY / scale;
const visibleRight = visibleLeft + containerWidth / scale;
const visibleBottom = visibleTop + containerHeight / scale;
return {
left: Math.max(0, visibleLeft),
top: Math.max(0, visibleTop),
right: Math.min(CANVAS_SIZE, visibleRight),
bottom: Math.min(CANVAS_SIZE, visibleBottom),
scale,
};
}
/**
* Calculate edge position for a cursor outside the viewport
*/
function calculateEdgePosition(
cursor: UserCursor,
bounds: ReturnType<typeof getVisibleBounds>,
containerWidth: number,
containerHeight: number
): CursorViewportInfo['edgePosition'] {
const { left, top, right, bottom, scale } = bounds;
const { x: cursorX, y: cursorY } = cursor;
// Determine which edge(s) the cursor is beyond
const isAbove = cursorY < top;
const isBelow = cursorY > bottom;
const isLeft = cursorX < left;
const isRight = cursorX > right;
// Calculate clamped position at viewport edges
const clampedX = Math.max(left, Math.min(right, cursorX));
const clampedY = Math.max(top, Math.min(bottom, cursorY));
// Convert clamped canvas position to screen coordinates
let screenX = (clampedX - left) * scale;
let screenY = (clampedY - top) * scale;
// Determine primary side and adjust to edge with margin
let side: 'top' | 'bottom' | 'left' | 'right';
let distance: number;
// Priority: vertical edges over horizontal if both apply
if (isLeft || isRight) {
if (isLeft) {
side = 'left';
screenX = EDGE_INDICATOR_MARGIN;
distance = left - cursorX;
} else {
side = 'right';
screenX = containerWidth - EDGE_INDICATOR_MARGIN;
distance = cursorX - right;
}
} else if (isAbove || isBelow) {
if (isAbove) {
side = 'top';
screenY = EDGE_INDICATOR_MARGIN;
distance = top - cursorY;
} else {
side = 'bottom';
screenY = containerHeight - EDGE_INDICATOR_MARGIN;
distance = cursorY - bottom;
}
} else {
// Should not happen, but handle gracefully
side = 'top';
distance = 0;
}
// Clamp screen positions to container bounds with margin
screenX = Math.max(EDGE_INDICATOR_MARGIN, Math.min(containerWidth - EDGE_INDICATOR_MARGIN, screenX));
screenY = Math.max(EDGE_INDICATOR_MARGIN, Math.min(containerHeight - EDGE_INDICATOR_MARGIN, screenY));
return {
x: screenX,
y: screenY,
side,
distance,
};
}
/**
* Track cursor positions relative to viewport
*/
export function useCursorViewportTracking({
cursors,
viewport,
transformRef,
containerWidth,
containerHeight,
}: UseCursorViewportTrackingOptions): CursorViewportInfo[] {
return useMemo(() => {
if (containerWidth === 0 || containerHeight === 0) {
return [];
}
const bounds = getVisibleBounds(viewport, transformRef, containerWidth, containerHeight);
return Object.values(cursors).map((cursor) => {
const { x, y } = cursor;
const { left, top, right, bottom } = bounds;
// Check if cursor is within visible bounds
const isVisible = x >= left && x <= right && y >= top && y <= bottom;
if (isVisible) {
return { cursor, isVisible: true };
}
// Calculate edge position for off-screen cursor
const edgePosition = calculateEdgePosition(cursor, bounds, containerWidth, containerHeight);
return {
cursor,
isVisible: false,
edgePosition,
};
});
}, [cursors, viewport, transformRef, containerWidth, containerHeight]);
}

View File

@@ -0,0 +1,105 @@
/**
* Hook to receive and apply Explore state changes from CRDT
*
* This hook watches for CRDT operations that update panel explore state
* and applies them to the local Explore pane so the user sees changes
* made by other collaborators in real-time.
*/
import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'app/types/store';
import { changeDatasource } from '../../explore/state/datasource';
import { setQueriesAction } from '../../explore/state/query';
import { updateTime } from '../../explore/state/time';
interface UseExploreStateReceiverOptions {
panelId: string;
exploreId: string;
enabled?: boolean;
}
/**
* Hook to receive and apply explore state changes from CRDT
*/
export function useExploreStateReceiver(options: UseExploreStateReceiverOptions) {
const { panelId, exploreId, enabled = true } = options;
const dispatch = useDispatch();
// Get the panel's explore state from CRDT
const panels = useSelector((state) => {
const crdtStateJSON = state.exploreMapCRDT.crdtStateJSON;
if (!crdtStateJSON) {
return {};
}
const parsed = JSON.parse(crdtStateJSON);
const panelData: Record<string, { exploreState?: { value: any } }> = {};
for (const [id, data] of Object.entries(parsed.panelData || {})) {
panelData[id] = data as { exploreState?: { value: any } };
}
return panelData;
});
const panel = panels[panelId];
const exploreState = panel?.exploreState?.value;
// Get current Explore pane state for comparison
const explorePane = useSelector((state) => state.explore?.panes?.[exploreId]);
// Track what we've already applied to avoid loops
const lastAppliedStateRef = useRef<string | null>(null);
useEffect(() => {
if (!enabled || !exploreState || !explorePane) {
return;
}
const exploreStateStr = JSON.stringify(exploreState);
// Skip if we've already applied this exact state
if (lastAppliedStateRef.current === exploreStateStr) {
return;
}
lastAppliedStateRef.current = exploreStateStr;
// Apply queries if they've changed
if (exploreState.queries && JSON.stringify(exploreState.queries) !== JSON.stringify(explorePane.queries)) {
dispatch(setQueriesAction({
exploreId,
queries: exploreState.queries,
}));
}
// Apply datasource if it's changed
if (exploreState.datasourceUid && exploreState.datasourceUid !== explorePane.datasourceInstance?.uid) {
dispatch(changeDatasource({
exploreId,
datasource: exploreState.datasourceUid,
}));
}
// Apply time range if it's changed
if (exploreState.range && JSON.stringify(exploreState.range) !== JSON.stringify(explorePane.range)) {
// If range.raw exists, use it (for properly structured TimeRange objects)
// Otherwise, treat the range itself as a RawTimeRange (for backward compatibility)
const rawRange = (exploreState.range as any).raw || exploreState.range;
dispatch(updateTime({
exploreId,
rawRange: rawRange,
}));
}
// Note: We don't sync refreshInterval, panelsState, or compact mode automatically
// as those are more UI preference than data state
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
enabled,
panelId,
exploreId,
// Intentionally not including exploreState or explorePane to avoid excessive re-renders
// We use refs inside the effect to detect actual changes
dispatch,
]);
}

View File

@@ -0,0 +1,89 @@
/**
* Hook to synchronize Explore pane state changes to CRDT
*
* This hook watches for changes to the Explore pane (queries, datasource, range, etc.)
* and broadcasts them via CRDT operations so other users see the changes in real-time.
*/
import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'app/types/store';
import { savePanelExploreState } from '../state/crdtSlice';
import { SerializedExploreState } from '../state/types';
interface UseExploreStateSyncOptions {
panelId: string;
exploreId: string;
enabled?: boolean;
}
/**
* Debounce delay for syncing explore state changes
* We don't want to broadcast every keystroke, so we wait a bit
*/
const SYNC_DELAY_MS = 1000;
export function useExploreStateSync(options: UseExploreStateSyncOptions) {
const { panelId, exploreId, enabled = true } = options;
const dispatch = useDispatch();
// Get the explore pane state from Redux
const explorePane = useSelector((state) => state.explore?.panes?.[exploreId]);
// Track previous state to detect changes
const previousStateRef = useRef<string | null>(null);
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!enabled || !explorePane) {
return;
}
// Serialize the current explore state
const currentState: SerializedExploreState = {
queries: explorePane.queries,
datasourceUid: explorePane.datasourceInstance?.uid,
range: explorePane.range,
refreshInterval: explorePane.refreshInterval,
panelsState: explorePane.panelsState,
compact: explorePane.compact,
};
const currentStateStr = JSON.stringify(currentState);
// Check if state has actually changed
if (previousStateRef.current === currentStateStr) {
return;
}
previousStateRef.current = currentStateStr;
// Clear any pending sync
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
// Debounce the sync operation
syncTimeoutRef.current = setTimeout(() => {
dispatch(savePanelExploreState({
panelId,
exploreState: currentState,
}));
}, SYNC_DELAY_MS);
// Cleanup timeout on unmount
return () => {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
enabled,
panelId,
// Intentionally not including explorePane to avoid excessive re-renders
// We check previousStateRef inside the effect to detect actual changes
dispatch,
]);
}

View File

@@ -0,0 +1,290 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
/**
* React hook to track active users for a specific map
*
* This hook subscribes to Grafana Live cursor updates for a map
* and returns the list of currently active users.
*
* If updateRedux is true, it also updates the Redux state so that
* the toolbar selector can see these users when viewing this map.
*/
import { useEffect, useRef, useState } from 'react';
import { Unsubscribable } from 'rxjs';
import { LiveChannelAddress, LiveChannelScope, isLiveChannelMessageEvent, store } from '@grafana/data';
import { getGrafanaLiveSrv } from '@grafana/runtime';
import { UserView } from '@grafana/ui';
import { StoreState, useDispatch, useSelector } from 'app/types/store';
import { updateCursor, removeCursor } from '../state/crdtSlice';
import { selectSessionId } from '../state/selectors';
import { UserCursor } from '../state/types';
interface CursorUpdateMessage {
type: 'cursor_update';
sessionId: string;
userId: string;
userName: string;
data: {
x: number;
y: number;
color: string;
selectedPanelIds: string[];
};
timestamp: number;
}
interface CursorLeaveMessage {
type: 'cursor_leave';
sessionId: string;
userId: string;
userName: string;
timestamp: number;
}
type CursorMessage = CursorUpdateMessage | CursorLeaveMessage;
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
const MAX_HISTORY_MS = 24 * 60 * 60 * 1000; // 24 hours - keep user history for this long
const STORAGE_KEY_PREFIX = 'grafana.exploreMap.users.';
// Helper to get localStorage key for a map
const getStorageKey = (mapUid: string) => `${STORAGE_KEY_PREFIX}${mapUid}`;
// Load user history from storage
const loadUserHistoryFromStorage = (mapUid: string): Map<string, { userId: string; userName: string; lastUpdated: number }> => {
try {
const key = getStorageKey(mapUid);
const stored = store.get(key);
if (!stored) {
return new Map();
}
const data = typeof stored === 'string' ? JSON.parse(stored) : stored;
const now = Date.now();
const history = new Map<string, { userId: string; userName: string; lastUpdated: number }>();
// Only load users active in the last 15 minutes
for (const [userId, userData] of Object.entries(data)) {
const user = userData as { userId: string; userName: string; lastUpdated: number };
if (now - user.lastUpdated <= STALE_THRESHOLD_MS) {
history.set(userId, user);
}
}
return history;
} catch (error) {
console.warn('Failed to load user history from storage:', error);
return new Map();
}
};
// Save user history to storage
const saveUserHistoryToStorage = (mapUid: string, history: Map<string, { userId: string; userName: string; lastUpdated: number }>) => {
try {
const key = getStorageKey(mapUid);
const data: Record<string, { userId: string; userName: string; lastUpdated: number }> = {};
for (const [userId, user] of history.entries()) {
data[userId] = user;
}
store.set(key, JSON.stringify(data));
} catch (error) {
console.warn('Failed to save user history to storage:', error);
}
};
export function useMapActiveUsers(
mapUid: string | undefined,
enabled = true,
updateRedux = false,
showAllUsers = false
): UserView[] {
const [activeUsers, setActiveUsers] = useState<UserView[]>([]);
const subscriptionRef = useRef<Unsubscribable | null>(null);
// Track all users who have been active, even after they disconnect
const usersHistoryRef = useRef<Map<string, { userId: string; userName: string; lastUpdated: number }>>(new Map());
const dispatch = useDispatch();
const sessionId = useSelector((state: StoreState) => selectSessionId(state.exploreMapCRDT));
useEffect(() => {
if (!enabled || !mapUid) {
setActiveUsers([]);
return;
}
// Load user history from storage when mapUid changes
usersHistoryRef.current = loadUserHistoryFromStorage(mapUid);
// If updateRedux is true, populate Redux state with loaded history
// This ensures the toolbar shows users immediately on page load
if (updateRedux) {
const now = Date.now();
for (const user of usersHistoryRef.current.values()) {
// Only add users active within the threshold
if (now - user.lastUpdated <= STALE_THRESHOLD_MS) {
// Create a synthetic cursor for Redux state
// Use userId as sessionId with a suffix to indicate it's from history
const cursor: UserCursor = {
userId: user.userId,
sessionId: `${user.userId}-history`,
userName: user.userName,
color: '#4ECDC4', // Default color
x: 0,
y: 0,
lastUpdated: user.lastUpdated,
selectedPanelIds: [],
};
dispatch(updateCursor(cursor));
}
}
}
let isSubscribed = true;
const updateActiveUsers = () => {
const now = Date.now();
const userMap = new Map<string, { userId: string; userName: string; lastUpdated: number }>();
// Process all users from history
for (const user of usersHistoryRef.current.values()) {
// Remove users older than MAX_HISTORY_MS
if (now - user.lastUpdated > MAX_HISTORY_MS) {
usersHistoryRef.current.delete(user.userId);
continue;
}
// If showAllUsers is false, filter out stale users (older than threshold)
if (!showAllUsers && now - user.lastUpdated > STALE_THRESHOLD_MS) {
continue;
}
// Keep the most recent entry for each user
const existing = userMap.get(user.userId);
if (!existing || user.lastUpdated > existing.lastUpdated) {
userMap.set(user.userId, user);
}
}
// Save updated history to storage (clean up old entries)
saveUserHistoryToStorage(mapUid, usersHistoryRef.current);
// Convert to UserView format
const users = Array.from(userMap.values())
.map((user) => ({
user: {
name: user.userName,
},
lastActiveAt: new Date(user.lastUpdated).toISOString(),
}))
.sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime());
setActiveUsers(users);
};
// Update active users immediately with loaded history
updateActiveUsers();
const connect = () => {
try {
const liveService = getGrafanaLiveSrv();
if (!liveService) {
return;
}
const channelAddress: LiveChannelAddress = {
scope: LiveChannelScope.Grafana,
namespace: 'explore-map',
path: mapUid,
};
const subscription = liveService.getStream<CursorMessage>(channelAddress).subscribe({
next: (event) => {
if (!isSubscribed) {
return;
}
try {
if (isLiveChannelMessageEvent(event)) {
const message = event.message as CursorMessage;
// Skip our own messages to avoid showing local cursor
if (message.sessionId === sessionId) {
return;
}
if (message.type === 'cursor_update') {
// Update user history (persist even after disconnect)
usersHistoryRef.current.set(message.userId, {
userId: message.userId,
userName: message.userName,
lastUpdated: message.timestamp,
});
// Save to localStorage periodically (debounced)
saveUserHistoryToStorage(mapUid, usersHistoryRef.current);
// Update Redux state if requested (for toolbar visibility)
if (updateRedux) {
const cursor: UserCursor = {
userId: message.userId,
sessionId: message.sessionId,
userName: message.userName,
color: message.data.color,
x: message.data.x,
y: message.data.y,
lastUpdated: message.timestamp,
selectedPanelIds: message.data.selectedPanelIds,
};
dispatch(updateCursor(cursor));
}
} else if (message.type === 'cursor_leave') {
// Don't remove from history on leave - keep for showing last active
// Update Redux state if requested
if (updateRedux) {
dispatch(removeCursor({ sessionId: message.sessionId }));
}
}
// Update active users list
updateActiveUsers();
}
} catch (error) {
// Silently ignore parsing errors
}
},
error: () => {
// Channel error - connection will be retried automatically
},
});
subscriptionRef.current = subscription;
} catch (error) {
// Failed to connect - will retry on next mount
}
};
// Clean up stale cursors periodically
const cleanupInterval = setInterval(() => {
updateActiveUsers();
}, 5000); // Check every 5 seconds
connect();
return () => {
isSubscribed = false;
if (subscriptionRef.current) {
subscriptionRef.current.unsubscribe();
subscriptionRef.current = null;
}
// Don't clear history on unmount - keep it for showing last active users
// Only clear if explicitly needed (e.g., when mapUid changes)
clearInterval(cleanupInterval);
};
}, [mapUid, enabled, updateRedux, showAllUsers, dispatch, sessionId]);
return activeUsers;
}

View File

@@ -0,0 +1,112 @@
/**
* Hook to sync panel explore state when panel is deselected
*
* This hook monitors panel selection changes and syncs the explore state
* to CRDT when a panel is deselected, but only if the state has changed.
*/
import { useEffect, useRef } from 'react';
import { shallowEqual } from 'react-redux';
import { useDispatch, useSelector } from 'app/types/store';
import { savePanelExploreState } from '../state/crdtSlice';
import { selectSelectedPanelIds, selectPanels } from '../state/selectors';
import { SerializedExploreState } from '../state/types';
export interface UsePanelStateSyncOptions {
panelId: string;
exploreId: string;
}
/**
* Hook to sync panel explore state when it's deselected
*/
export function usePanelStateSync({ panelId, exploreId }: UsePanelStateSyncOptions) {
const dispatch = useDispatch();
// Track if this panel was previously selected
const wasSelectedRef = useRef(false);
// Track the last synced state
const lastSyncedStateRef = useRef<string | null>(null);
// Get current selection state - memoized to only change when THIS panel's selection changes
const isSelected = useSelector((state) => {
const selectedIds = selectSelectedPanelIds(state.exploreMapCRDT);
return selectedIds.includes(panelId);
});
// Get current explore state - using shallowEqual to prevent re-renders when content is the same
const explorePane = useSelector((state) => state.explore?.panes?.[exploreId], shallowEqual);
// Get the panel's saved exploreState only - using shallowEqual to prevent re-renders
const panelExploreState = useSelector((state) => {
const panels = selectPanels(state.exploreMapCRDT);
return panels[panelId]?.exploreState;
}, shallowEqual);
useEffect((): void | (() => void) => {
// Update tracking ref
const previouslySelected = wasSelectedRef.current;
wasSelectedRef.current = isSelected;
// Detect deselection (was selected, now not selected)
if (previouslySelected && !isSelected) {
// Add a small delay to ensure explore state updates have been committed to Redux
const syncTimer = setTimeout(() => {
// Get current explore state (after delay to ensure it's updated)
if (!explorePane) {
return;
}
// Extract the key fields we care about
const currentState: SerializedExploreState = {
queries: explorePane.queries || [],
datasourceUid: explorePane.datasourceInstance?.uid,
range: explorePane.range,
refreshInterval: explorePane.refreshInterval,
panelsState: explorePane.panelsState,
compact: explorePane.compact,
};
// Create a stable string representation focusing on the important fields
const currentStateStr = JSON.stringify({
datasourceUid: currentState.datasourceUid,
queries: currentState.queries,
range: currentState.range,
});
// Check if state has changed since last sync
const hasChanged = lastSyncedStateRef.current !== currentStateStr;
if (hasChanged) {
// Dispatch sync action with full state
dispatch(savePanelExploreState({
panelId,
exploreState: currentState,
}));
// Update last synced state
lastSyncedStateRef.current = currentStateStr;
}
}, 100); // Small delay to ensure state is updated
// Return cleanup function
return () => clearTimeout(syncTimer);
}
}, [isSelected, panelId, exploreId, explorePane, dispatch]);
// Initialize last synced state from panel's saved state
useEffect(() => {
if (panelExploreState && lastSyncedStateRef.current === null) {
// Use the same format as in the main effect for consistency
const initStateStr = JSON.stringify({
datasourceUid: panelExploreState.datasourceUid,
queries: panelExploreState.queries || [],
range: panelExploreState.range,
});
lastSyncedStateRef.current = initStateStr;
}
}, [panelExploreState]);
}

View File

@@ -0,0 +1,314 @@
/**
* Operation creator functions
*
* Helper functions to create well-formed CRDT operations.
* These are used by the Redux layer to convert user actions into operations.
*/
import { v4 as uuidv4 } from 'uuid';
import { HLCTimestamp } from '../crdt/hlc';
import {
CRDTOperation,
AddPanelOperation,
RemovePanelOperation,
UpdatePanelPositionOperation,
UpdatePanelSizeOperation,
UpdatePanelZIndexOperation,
UpdatePanelExploreStateOperation,
UpdateTitleOperation,
AddCommentOperation,
RemoveCommentOperation,
BatchOperation,
CommentData,
} from '../crdt/types';
import { SerializedExploreState } from '../state/types';
/**
* Create an add panel operation
*/
export function createAddPanelOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
panelId: string;
exploreId: string;
position: {
x: number;
y: number;
width: number;
height: number;
};
}
): AddPanelOperation {
return {
type: 'add-panel',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create a remove panel operation
*/
export function createRemovePanelOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
panelId: string;
observedTags: string[];
}
): RemovePanelOperation {
return {
type: 'remove-panel',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create an update panel position operation
*/
export function createUpdatePanelPositionOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
panelId: string;
x: number;
y: number;
}
): UpdatePanelPositionOperation {
return {
type: 'update-panel-position',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create an update panel size operation
*/
export function createUpdatePanelSizeOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
panelId: string;
width: number;
height: number;
}
): UpdatePanelSizeOperation {
return {
type: 'update-panel-size',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create an update panel z-index operation
*/
export function createUpdatePanelZIndexOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
panelId: string;
zIndex: number;
}
): UpdatePanelZIndexOperation {
return {
type: 'update-panel-zindex',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create an update panel explore state operation
*/
export function createUpdatePanelExploreStateOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
panelId: string;
exploreState: SerializedExploreState | undefined;
forceReload?: boolean,
}
): UpdatePanelExploreStateOperation {
return {
type: 'update-panel-explore-state',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create an update title operation
*/
export function createUpdateTitleOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
title: string;
}
): UpdateTitleOperation {
return {
type: 'update-title',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create an add comment operation
*/
export function createAddCommentOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
commentId: string;
comment: CommentData;
}
): AddCommentOperation {
return {
type: 'add-comment',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create a remove comment operation
*/
export function createRemoveCommentOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
payload: {
commentId: string;
observedTags: string[];
}
): RemoveCommentOperation {
return {
type: 'remove-comment',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload,
};
}
/**
* Create a batch operation containing multiple sub-operations
*/
export function createBatchOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
operations: CRDTOperation[]
): BatchOperation {
return {
type: 'batch',
mapUid,
operationId: uuidv4(),
timestamp,
nodeId,
payload: {
operations,
},
};
}
/**
* Create an operation to move multiple panels together
* Returns a batch operation with position updates for all panels
*/
export function createMultiPanelMoveOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
panelMoves: Array<{ panelId: string; x: number; y: number }>
): BatchOperation {
const operations = panelMoves.map((move) =>
createUpdatePanelPositionOperation(mapUid, nodeId, timestamp, move)
);
return createBatchOperation(mapUid, nodeId, timestamp, operations);
}
/**
* Create an operation to duplicate a panel
* Returns a batch operation that adds a new panel with the same properties
*/
export function createDuplicatePanelOperation(
mapUid: string,
nodeId: string,
timestamp: HLCTimestamp,
sourcePanel: {
id: string;
exploreId: string;
position: { x: number; y: number; width: number; height: number };
exploreState?: SerializedExploreState;
},
offset: { x: number; y: number }
): BatchOperation {
const newPanelId = uuidv4();
const newExploreId = `explore-${uuidv4()}`;
const operations: CRDTOperation[] = [
createAddPanelOperation(mapUid, nodeId, timestamp, {
panelId: newPanelId,
exploreId: newExploreId,
position: {
x: sourcePanel.position.x + offset.x,
y: sourcePanel.position.y + offset.y,
width: sourcePanel.position.width,
height: sourcePanel.position.height,
},
}),
];
// If source panel has explore state, copy it
if (sourcePanel.exploreState) {
operations.push(
createUpdatePanelExploreStateOperation(mapUid, nodeId, timestamp, {
panelId: newPanelId,
exploreState: sourcePanel.exploreState,
})
);
}
return createBatchOperation(mapUid, nodeId, timestamp, operations);
}

View File

@@ -0,0 +1,45 @@
/**
* Operations module exports
*
* CRDT operation management: queue, validation, creation, serialization
*/
// Operation queue
export { OperationQueue } from './queue';
export type { QueuedOperation, OperationQueueStats } from './queue';
// Validators
export { validateOperation, quickValidate } from './validators';
export type { ValidationResult, ValidationOptions } from './validators';
// Operation creators
export {
createAddPanelOperation,
createRemovePanelOperation,
createUpdatePanelPositionOperation,
createUpdatePanelSizeOperation,
createUpdatePanelZIndexOperation,
createUpdatePanelExploreStateOperation,
createUpdateTitleOperation,
createAddCommentOperation,
createRemoveCommentOperation,
createBatchOperation,
createMultiPanelMoveOperation,
createDuplicatePanelOperation,
} from './creators';
// Serialization
export {
serializeOperation,
deserializeOperation,
serializeOperations,
deserializeOperations,
serializeForWebSocket,
deserializeFromWebSocket,
compressOperation,
decompressOperation,
estimateOperationSize,
batchOperations,
deduplicateOperations,
} from './serialization';
export type { WebSocketMessage } from './serialization';

View File

@@ -0,0 +1,288 @@
/**
* Operation Queue for CRDT operations
*
* Manages local and remote operations, ensuring they are applied in causal order.
* Handles deduplication, ordering, and buffering of out-of-order operations.
*/
import { HybridLogicalClock, compareHLC, HLCTimestamp } from '../crdt/hlc';
import { CRDTOperation } from '../crdt/types';
export interface QueuedOperation {
operation: CRDTOperation;
source: 'local' | 'remote';
enqueuedAt: number; // Timestamp when added to queue
}
export interface OperationQueueStats {
pendingCount: number;
appliedCount: number;
localCount: number;
remoteCount: number;
}
/**
* Operation queue that maintains causal ordering of CRDT operations
*/
export class OperationQueue {
private clock: HybridLogicalClock;
private nodeId: string;
// Operations waiting to be applied (sorted by HLC timestamp)
private pendingQueue: QueuedOperation[] = [];
// Set of operation IDs that have been applied (for deduplication)
private appliedOperations: Set<string> = new Set();
// Maximum number of applied operation IDs to keep in memory
private readonly maxAppliedHistorySize = 10000;
constructor(nodeId: string) {
this.nodeId = nodeId;
this.clock = new HybridLogicalClock(nodeId);
}
/**
* Get the current node ID
*/
getNodeId(): string {
return this.nodeId;
}
/**
* Get the current HLC
*/
getClock(): HybridLogicalClock {
return this.clock;
}
/**
* Add a local operation to the queue
* Automatically assigns a new timestamp from the local clock
*/
addLocalOperation(operation: CRDTOperation): CRDTOperation {
// Tick clock for new local event
const timestamp = this.clock.tick();
// Create operation with new timestamp
const timedOperation: CRDTOperation = {
...operation,
timestamp,
nodeId: this.nodeId,
};
// Add to pending queue
this.enqueueOperation(timedOperation, 'local');
return timedOperation;
}
/**
* Add a remote operation to the queue
* Updates local clock based on received timestamp
*/
addRemoteOperation(operation: CRDTOperation): boolean {
// Check if already applied (deduplication)
if (this.appliedOperations.has(operation.operationId)) {
return false; // Already applied, ignore
}
// Check if already in pending queue
if (this.pendingQueue.some((q) => q.operation.operationId === operation.operationId)) {
return false; // Already queued, ignore
}
// Update clock with received timestamp
this.clock.update(operation.timestamp);
// Add to pending queue
this.enqueueOperation(operation, 'remote');
return true;
}
/**
* Internal method to enqueue an operation and maintain sorted order
*/
private enqueueOperation(operation: CRDTOperation, source: 'local' | 'remote'): void {
const queued: QueuedOperation = {
operation,
source,
enqueuedAt: Date.now(),
};
// Insert in sorted order by HLC timestamp
const insertIndex = this.findInsertIndex(operation.timestamp);
this.pendingQueue.splice(insertIndex, 0, queued);
}
/**
* Binary search to find insertion index for maintaining sorted order
*/
private findInsertIndex(timestamp: HLCTimestamp): number {
let left = 0;
let right = this.pendingQueue.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
const comparison = compareHLC(this.pendingQueue[mid].operation.timestamp, timestamp);
if (comparison < 0) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
/**
* Dequeue the next operation to apply
* Returns undefined if queue is empty
*/
dequeue(): CRDTOperation | undefined {
const queued = this.pendingQueue.shift();
if (!queued) {
return undefined;
}
// Mark as applied
this.markApplied(queued.operation.operationId);
return queued.operation;
}
/**
* Peek at the next operation without removing it
*/
peek(): CRDTOperation | undefined {
return this.pendingQueue[0]?.operation;
}
/**
* Get all pending operations (without removing them)
*/
getPendingOperations(): CRDTOperation[] {
return this.pendingQueue.map((q) => q.operation);
}
/**
* Check if an operation has been applied
*/
hasApplied(operationId: string): boolean {
return this.appliedOperations.has(operationId);
}
/**
* Mark an operation as applied
*/
markApplied(operationId: string): void {
this.appliedOperations.add(operationId);
// Limit memory usage by removing old entries
if (this.appliedOperations.size > this.maxAppliedHistorySize) {
this.pruneAppliedHistory();
}
}
/**
* Prune old entries from applied operations set
* Removes the oldest 10% of entries
*/
private pruneAppliedHistory(): void {
const toRemove = Math.floor(this.maxAppliedHistorySize * 0.1);
const entries = Array.from(this.appliedOperations);
// Remove first 10% (oldest)
for (let i = 0; i < toRemove && i < entries.length; i++) {
this.appliedOperations.delete(entries[i]);
}
}
/**
* Get the number of pending operations
*/
getPendingCount(): number {
return this.pendingQueue.length;
}
/**
* Check if queue is empty
*/
isEmpty(): boolean {
return this.pendingQueue.length === 0;
}
/**
* Clear all pending operations (useful for testing/reset)
*/
clearPending(): void {
this.pendingQueue = [];
}
/**
* Clear applied operations history
*/
clearApplied(): void {
this.appliedOperations.clear();
}
/**
* Get queue statistics
*/
getStats(): OperationQueueStats {
const localCount = this.pendingQueue.filter((q) => q.source === 'local').length;
const remoteCount = this.pendingQueue.filter((q) => q.source === 'remote').length;
return {
pendingCount: this.pendingQueue.length,
appliedCount: this.appliedOperations.size,
localCount,
remoteCount,
};
}
/**
* Drain all operations from the queue in order
* Returns array of operations in causal order
*/
drainAll(): CRDTOperation[] {
const operations: CRDTOperation[] = [];
while (!this.isEmpty()) {
const op = this.dequeue();
if (op) {
operations.push(op);
}
}
return operations;
}
/**
* Get operations older than a certain age (in milliseconds)
* Useful for detecting stale operations
*/
getStaleOperations(maxAge: number): CRDTOperation[] {
const now = Date.now();
return this.pendingQueue
.filter((q) => now - q.enqueuedAt > maxAge)
.map((q) => q.operation);
}
/**
* Remove specific operations by ID
* Returns number of operations removed
*/
removeOperations(operationIds: string[]): number {
const idsToRemove = new Set(operationIds);
const initialLength = this.pendingQueue.length;
this.pendingQueue = this.pendingQueue.filter(
(q) => !idsToRemove.has(q.operation.operationId)
);
return initialLength - this.pendingQueue.length;
}
}

View File

@@ -0,0 +1,320 @@
/**
* Operation serialization utilities
*
* Handles serialization/deserialization of CRDT operations for network transmission
* and storage. Supports both JSON and potential future binary formats.
*/
import { CRDTOperation } from '../crdt/types';
/**
* Serialize an operation to JSON string
*/
export function serializeOperation(operation: CRDTOperation): string {
return JSON.stringify(operation);
}
/**
* Deserialize an operation from JSON string
*/
export function deserializeOperation(json: string): CRDTOperation {
return JSON.parse(json);
}
/**
* Serialize multiple operations to JSON string
*/
export function serializeOperations(operations: CRDTOperation[]): string {
return JSON.stringify(operations);
}
/**
* Deserialize multiple operations from JSON string
*/
export function deserializeOperations(json: string): CRDTOperation[] {
return JSON.parse(json);
}
/**
* Serialize operation for WebSocket transmission
* Adds metadata for efficient routing
*/
export interface WebSocketMessage {
type: 'operation' | 'batch' | 'sync-request' | 'sync-response';
mapUid: string;
data: CRDTOperation | CRDTOperation[];
timestamp: number; // Message sent timestamp
}
export function serializeForWebSocket(
operation: CRDTOperation | CRDTOperation[]
): string {
const operations = Array.isArray(operation) ? operation : [operation];
const mapUid = operations[0]?.mapUid || '';
const message: WebSocketMessage = {
type: Array.isArray(operation) ? 'batch' : 'operation',
mapUid,
data: operation,
timestamp: Date.now(),
};
return JSON.stringify(message);
}
export function deserializeFromWebSocket(json: string): WebSocketMessage {
return JSON.parse(json);
}
/**
* Compress operation by removing redundant data
* Useful for bandwidth optimization
*/
export function compressOperation(operation: CRDTOperation): any {
// Remove verbose field names, use short aliases
const compressed: any = {
t: operation.type,
i: operation.operationId,
m: operation.mapUid,
n: operation.nodeId,
ts: {
l: operation.timestamp.logicalTime,
w: operation.timestamp.wallTime,
n: operation.timestamp.nodeId,
},
p: compressPayload(operation),
};
return compressed;
}
function compressPayload(operation: CRDTOperation): any {
const payload = (operation as any).payload;
if (!payload) {
return undefined;
}
// Type-specific compression
switch (operation.type) {
case 'add-panel':
return {
pi: payload.panelId,
ei: payload.exploreId,
pos: {
x: payload.position.x,
y: payload.position.y,
w: payload.position.width,
h: payload.position.height,
},
};
case 'remove-panel':
return {
pi: payload.panelId,
t: payload.observedTags,
};
case 'update-panel-position':
return {
pi: payload.panelId,
x: payload.x,
y: payload.y,
};
case 'update-panel-size':
return {
pi: payload.panelId,
w: payload.width,
h: payload.height,
};
case 'update-panel-zindex':
return {
pi: payload.panelId,
z: payload.zIndex,
};
case 'update-panel-explore-state':
return {
pi: payload.panelId,
es: payload.exploreState,
};
case 'update-title':
return {
t: payload.title,
};
case 'add-comment':
return {
cid: payload.commentId,
ct: payload.comment.text,
cu: payload.comment.username,
cts: payload.comment.timestamp,
};
case 'remove-comment':
return {
cid: payload.commentId,
ot: payload.observedTags,
};
case 'batch':
return {
ops: payload.operations.map(compressOperation),
};
default:
return payload;
}
}
/**
* Decompress operation from compressed format
*/
export function decompressOperation(compressed: any): CRDTOperation {
const base = {
type: compressed.t,
operationId: compressed.i,
mapUid: compressed.m,
nodeId: compressed.n,
timestamp: {
logicalTime: compressed.ts.l,
wallTime: compressed.ts.w,
nodeId: compressed.ts.n,
},
};
const payload = decompressPayload(compressed.t, compressed.p);
return {
...base,
payload,
} as CRDTOperation;
}
function decompressPayload(type: string, compressed: any): any {
if (!compressed) {
return undefined;
}
switch (type) {
case 'add-panel':
return {
panelId: compressed.pi,
exploreId: compressed.ei,
position: {
x: compressed.pos.x,
y: compressed.pos.y,
width: compressed.pos.w,
height: compressed.pos.h,
},
};
case 'remove-panel':
return {
panelId: compressed.pi,
observedTags: compressed.t,
};
case 'update-panel-position':
return {
panelId: compressed.pi,
x: compressed.x,
y: compressed.y,
};
case 'update-panel-size':
return {
panelId: compressed.pi,
width: compressed.w,
height: compressed.h,
};
case 'update-panel-zindex':
return {
panelId: compressed.pi,
zIndex: compressed.z,
};
case 'update-panel-explore-state':
return {
panelId: compressed.pi,
exploreState: compressed.es,
};
case 'update-title':
return {
title: compressed.t,
};
case 'add-comment':
return {
commentId: compressed.cid,
comment: {
text: compressed.ct,
username: compressed.cu,
timestamp: compressed.cts,
},
};
case 'remove-comment':
return {
commentId: compressed.cid,
observedTags: compressed.ot,
};
case 'batch':
return {
operations: compressed.ops.map(decompressOperation),
};
default:
return compressed;
}
}
/**
* Calculate approximate size of an operation in bytes
* Useful for monitoring bandwidth usage
*/
export function estimateOperationSize(operation: CRDTOperation): number {
const json = serializeOperation(operation);
// Approximate UTF-8 byte length (not exact, but close enough)
return new Blob([json]).size;
}
/**
* Batch multiple operations into a single message
* Useful for reducing WebSocket message overhead
*/
export function batchOperations(
operations: CRDTOperation[],
maxBatchSize = 10
): CRDTOperation[][] {
const batches: CRDTOperation[][] = [];
for (let i = 0; i < operations.length; i += maxBatchSize) {
batches.push(operations.slice(i, i + maxBatchSize));
}
return batches;
}
/**
* Deduplicate operations by operation ID
* Keeps the first occurrence of each unique operation ID
*/
export function deduplicateOperations(operations: CRDTOperation[]): CRDTOperation[] {
const seen = new Set<string>();
const deduplicated: CRDTOperation[] = [];
for (const operation of operations) {
if (!seen.has(operation.operationId)) {
seen.add(operation.operationId);
deduplicated.push(operation);
}
}
return deduplicated;
}

View File

@@ -0,0 +1,443 @@
/**
* Operation validators
*
* Validates CRDT operations for correctness, security, and schema compliance.
*/
import { CRDTOperation } from '../crdt/types';
export interface ValidationResult {
valid: boolean;
errors: string[];
}
export interface ValidationOptions {
maxPanelSize?: { width: number; height: number };
minPanelSize?: { width: number; height: number };
maxTitleLength?: number;
allowNegativeCoordinates?: boolean;
maxCoordinate?: number;
}
const DEFAULT_OPTIONS: Required<ValidationOptions> = {
maxPanelSize: { width: 5000, height: 5000 },
minPanelSize: { width: 100, height: 100 },
maxTitleLength: 255,
allowNegativeCoordinates: false,
maxCoordinate: 20000,
};
/**
* Validate a CRDT operation
*/
export function validateOperation(
operation: CRDTOperation,
options: ValidationOptions = {}
): ValidationResult {
const opts = { ...DEFAULT_OPTIONS, ...options };
const errors: string[] = [];
// Validate common fields
if (!operation.operationId || typeof operation.operationId !== 'string') {
errors.push('Operation ID is required and must be a string');
}
if (!operation.mapUid || typeof operation.mapUid !== 'string') {
errors.push('Map UID is required and must be a string');
}
if (!operation.nodeId || typeof operation.nodeId !== 'string') {
errors.push('Node ID is required and must be a string');
}
if (!operation.timestamp) {
errors.push('Timestamp is required');
} else {
validateTimestamp(operation.timestamp, errors);
}
// Validate type-specific fields
switch (operation.type) {
case 'add-panel':
validateAddPanel(operation, opts, errors);
break;
case 'remove-panel':
validateRemovePanel(operation, errors);
break;
case 'update-panel-position':
validateUpdatePanelPosition(operation, opts, errors);
break;
case 'update-panel-size':
validateUpdatePanelSize(operation, opts, errors);
break;
case 'update-panel-zindex':
validateUpdatePanelZIndex(operation, errors);
break;
case 'update-panel-explore-state':
validateUpdatePanelExploreState(operation, errors);
break;
case 'update-title':
validateUpdateTitle(operation, opts, errors);
break;
case 'add-comment':
validateAddComment(operation, opts, errors);
break;
case 'remove-comment':
validateRemoveComment(operation, opts, errors);
break;
case 'batch':
validateBatchOperation(operation, opts, errors);
break;
default:
errors.push(`Unknown operation type: ${(operation as any).type}`);
}
return {
valid: errors.length === 0,
errors,
};
}
function validateTimestamp(timestamp: any, errors: string[]): void {
if (typeof timestamp !== 'object' || timestamp === null) {
errors.push('Timestamp must be an object');
return;
}
if (typeof timestamp.logicalTime !== 'number' || timestamp.logicalTime < 0) {
errors.push('Timestamp logical time must be a non-negative number');
}
if (typeof timestamp.wallTime !== 'number' || timestamp.wallTime < 0) {
errors.push('Timestamp wall time must be a non-negative number');
}
if (typeof timestamp.nodeId !== 'string' || !timestamp.nodeId) {
errors.push('Timestamp node ID must be a non-empty string');
}
// Check for reasonable wall time (not too far in future)
const now = Date.now();
const maxFutureOffset = 60000; // 1 minute
if (timestamp.wallTime > now + maxFutureOffset) {
errors.push('Timestamp wall time is too far in the future');
}
}
function validateAddPanel(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
if (!operation.payload) {
errors.push('Add panel operation requires payload');
return;
}
const { panelId, exploreId, position } = operation.payload;
if (!panelId || typeof panelId !== 'string') {
errors.push('Panel ID is required and must be a string');
}
if (!exploreId || typeof exploreId !== 'string') {
errors.push('Explore ID is required and must be a string');
}
if (!position || typeof position !== 'object') {
errors.push('Position is required and must be an object');
return;
}
validatePosition(position, opts, errors);
}
function validateRemovePanel(operation: any, errors: string[]): void {
if (!operation.payload) {
errors.push('Remove panel operation requires payload');
return;
}
const { panelId, observedTags } = operation.payload;
if (!panelId || typeof panelId !== 'string') {
errors.push('Panel ID is required and must be a string');
}
if (!Array.isArray(observedTags)) {
errors.push('Observed tags must be an array');
} else if (observedTags.some((tag) => typeof tag !== 'string')) {
errors.push('All observed tags must be strings');
}
}
function validateUpdatePanelPosition(
operation: any,
opts: Required<ValidationOptions>,
errors: string[]
): void {
if (!operation.payload) {
errors.push('Update panel position operation requires payload');
return;
}
const { panelId, x, y } = operation.payload;
if (!panelId || typeof panelId !== 'string') {
errors.push('Panel ID is required and must be a string');
}
if (typeof x !== 'number') {
errors.push('X coordinate must be a number');
} else {
validateCoordinate('x', x, opts, errors);
}
if (typeof y !== 'number') {
errors.push('Y coordinate must be a number');
} else {
validateCoordinate('y', y, opts, errors);
}
}
function validateUpdatePanelSize(
operation: any,
opts: Required<ValidationOptions>,
errors: string[]
): void {
if (!operation.payload) {
errors.push('Update panel size operation requires payload');
return;
}
const { panelId, width, height } = operation.payload;
if (!panelId || typeof panelId !== 'string') {
errors.push('Panel ID is required and must be a string');
}
if (typeof width !== 'number') {
errors.push('Width must be a number');
} else {
validateDimension('width', width, opts, errors);
}
if (typeof height !== 'number') {
errors.push('Height must be a number');
} else {
validateDimension('height', height, opts, errors);
}
}
function validateUpdatePanelZIndex(operation: any, errors: string[]): void {
if (!operation.payload) {
errors.push('Update panel z-index operation requires payload');
return;
}
const { panelId, zIndex } = operation.payload;
if (!panelId || typeof panelId !== 'string') {
errors.push('Panel ID is required and must be a string');
}
if (typeof zIndex !== 'number' || zIndex < 0) {
errors.push('Z-index must be a non-negative number');
}
}
function validateUpdatePanelExploreState(operation: any, errors: string[]): void {
if (!operation.payload) {
errors.push('Update panel explore state operation requires payload');
return;
}
const { panelId, exploreState } = operation.payload;
if (!panelId || typeof panelId !== 'string') {
errors.push('Panel ID is required and must be a string');
}
// exploreState can be undefined, but if present must be an object
if (exploreState !== undefined && (typeof exploreState !== 'object' || exploreState === null)) {
errors.push('Explore state must be an object or undefined');
}
}
function validateUpdateTitle(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
if (!operation.payload) {
errors.push('Update title operation requires payload');
return;
}
const { title } = operation.payload;
if (typeof title !== 'string') {
errors.push('Title must be a string');
} else if (title.length > opts.maxTitleLength) {
errors.push(`Title exceeds maximum length of ${opts.maxTitleLength} characters`);
}
}
function validateAddComment(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
if (!operation.payload) {
errors.push('Add comment operation requires payload');
return;
}
const { commentId, comment } = operation.payload;
if (!commentId || typeof commentId !== 'string') {
errors.push('Comment ID is required and must be a string');
}
if (!comment || typeof comment !== 'object') {
errors.push('Comment must be an object');
return;
}
if (typeof comment.text !== 'string') {
errors.push('Comment text must be a string');
}
if (typeof comment.username !== 'string') {
errors.push('Comment username must be a string');
}
if (typeof comment.timestamp !== 'number') {
errors.push('Comment timestamp must be a number');
}
// Comments can be longer than titles, so we use a higher limit
// Using 10x the title limit for comments
const maxCommentLength = opts.maxTitleLength * 10;
if (comment.text && comment.text.length > maxCommentLength) {
errors.push(`Comment text exceeds maximum length of ${maxCommentLength} characters`);
}
}
function validateRemoveComment(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
if (!operation.payload) {
errors.push('Remove comment operation requires payload');
return;
}
const { commentId, observedTags } = operation.payload;
if (!commentId || typeof commentId !== 'string') {
errors.push('Comment ID is required and must be a string');
}
if (!Array.isArray(observedTags)) {
errors.push('Observed tags must be an array');
}
}
function validateBatchOperation(operation: any, opts: Required<ValidationOptions>, errors: string[]): void {
if (!operation.payload) {
errors.push('Batch operation requires payload');
return;
}
const { operations } = operation.payload;
if (!Array.isArray(operations)) {
errors.push('Batch operations must be an array');
return;
}
if (operations.length === 0) {
errors.push('Batch operation cannot be empty');
}
// Validate each sub-operation
operations.forEach((subOp: any, index: number) => {
const result = validateOperation(subOp, opts);
if (!result.valid) {
errors.push(`Batch operation[${index}]: ${result.errors.join(', ')}`);
}
});
}
function validatePosition(
position: any,
opts: Required<ValidationOptions>,
errors: string[]
): void {
const { x, y, width, height } = position;
if (typeof x !== 'number') {
errors.push('Position x must be a number');
} else {
validateCoordinate('x', x, opts, errors);
}
if (typeof y !== 'number') {
errors.push('Position y must be a number');
} else {
validateCoordinate('y', y, opts, errors);
}
if (typeof width !== 'number') {
errors.push('Position width must be a number');
} else {
validateDimension('width', width, opts, errors);
}
if (typeof height !== 'number') {
errors.push('Position height must be a number');
} else {
validateDimension('height', height, opts, errors);
}
}
function validateCoordinate(
name: string,
value: number,
opts: Required<ValidationOptions>,
errors: string[]
): void {
if (!Number.isFinite(value)) {
errors.push(`${name} must be a finite number`);
return;
}
if (!opts.allowNegativeCoordinates && value < 0) {
errors.push(`${name} cannot be negative`);
}
if (Math.abs(value) > opts.maxCoordinate) {
errors.push(`${name} exceeds maximum coordinate value of ${opts.maxCoordinate}`);
}
}
function validateDimension(
name: string,
value: number,
opts: Required<ValidationOptions>,
errors: string[]
): void {
if (!Number.isFinite(value)) {
errors.push(`${name} must be a finite number`);
return;
}
const minSize = name === 'width' ? opts.minPanelSize.width : opts.minPanelSize.height;
const maxSize = name === 'width' ? opts.maxPanelSize.width : opts.maxPanelSize.height;
if (value < minSize) {
errors.push(`${name} must be at least ${minSize}`);
}
if (value > maxSize) {
errors.push(`${name} cannot exceed ${maxSize}`);
}
}
/**
* Quick validation that only checks critical fields
* Useful for performance-sensitive paths
*/
export function quickValidate(operation: CRDTOperation): boolean {
return !!(
operation.operationId &&
operation.mapUid &&
operation.nodeId &&
operation.timestamp &&
operation.type
);
}

View File

@@ -0,0 +1,223 @@
/**
* React hook for real-time CRDT synchronization via Grafana Live
*
* This hook connects to the Grafana Live WebSocket channel for a map
* and handles bidirectional operation synchronization.
*/
import { useEffect, useRef, useState } from 'react';
import { Unsubscribable } from 'rxjs';
import { LiveChannelAddress, LiveChannelScope, isLiveChannelMessageEvent } from '@grafana/data';
import { getGrafanaLiveSrv } from '@grafana/runtime';
import { StoreState, useDispatch, useSelector } from 'app/types/store';
import { CRDTOperation } from '../crdt/types';
import { OperationQueue } from '../operations/queue';
import { applyOperation, clearPendingOperations, setOnlineStatus } from '../state/crdtSlice';
import { selectPendingOperations, selectNodeId } from '../state/selectors';
export interface RealtimeSyncOptions {
mapUid: string;
enabled?: boolean;
onError?: (error: Error) => void;
onConnected?: () => void;
onDisconnected?: () => void;
}
export interface RealtimeSyncStatus {
isConnected: boolean;
isInitialized: boolean;
error?: Error;
}
/**
* Hook to synchronize CRDT state with Grafana Live
*/
export function useRealtimeSync(options: RealtimeSyncOptions): RealtimeSyncStatus {
const { mapUid, enabled = true, onError, onConnected, onDisconnected } = options;
const dispatch = useDispatch();
const nodeId = useSelector((state: StoreState) => selectNodeId(state.exploreMapCRDT));
const pendingOperations = useSelector((state: StoreState) => selectPendingOperations(state.exploreMapCRDT));
const [status, setStatus] = useState<RealtimeSyncStatus>({
isConnected: false,
isInitialized: false,
});
const subscriptionRef = useRef<Unsubscribable | null>(null);
const queueRef = useRef<OperationQueue>(new OperationQueue(nodeId));
const appliedOpsRef = useRef<Set<string>>(new Set());
const channelAddressRef = useRef<LiveChannelAddress | null>(null);
useEffect(() => {
if (!enabled || !mapUid) {
return;
}
let isSubscribed = true;
const connect = () => {
try {
const liveService = getGrafanaLiveSrv();
if (!liveService) {
throw new Error('Grafana Live service not available');
}
// Create channel address for explore-map
const channelAddress: LiveChannelAddress = {
scope: LiveChannelScope.Grafana,
namespace: 'explore-map',
path: mapUid,
};
channelAddressRef.current = channelAddress;
// Subscribe to the channel stream
const subscription = liveService.getStream<CRDTOperation>(channelAddress).subscribe({
next: (event) => {
if (!isSubscribed) {
return;
}
try {
// Handle message events
if (isLiveChannelMessageEvent(event)) {
const message: any = event.message;
// Skip cursor-related messages (handled by useCursorSync)
if (message.type === 'cursor_update' || message.type === 'cursor_leave' || message.type === 'viewport_update') {
return;
}
// Handle CRDT operations
const operation: CRDTOperation = message;
// Skip if this is our own operation
if (operation.nodeId === nodeId) {
return;
}
// Skip if already applied
if (appliedOpsRef.current.has(operation.operationId)) {
return;
}
// Add to queue and apply
const added = queueRef.current.addRemoteOperation(operation);
if (added) {
appliedOpsRef.current.add(operation.operationId);
dispatch(applyOperation({ operation }));
}
}
} catch (error) {
console.error('[CRDT] Failed to handle incoming operation:', error);
if (onError && error instanceof Error) {
onError(error);
}
}
},
error: (error) => {
console.error('[CRDT] Channel error:', error);
setStatus((prev) => ({
...prev,
isConnected: false,
error: error instanceof Error ? error : new Error(String(error)),
}));
dispatch(setOnlineStatus({ isOnline: false }));
if (onError && error instanceof Error) {
onError(error);
}
if (onDisconnected) {
onDisconnected();
}
},
complete: () => {
if (isSubscribed) {
setStatus((prev) => ({ ...prev, isConnected: false }));
dispatch(setOnlineStatus({ isOnline: false }));
if (onDisconnected) {
onDisconnected();
}
}
},
});
subscriptionRef.current = subscription;
// Mark as connected
setStatus({
isConnected: true,
isInitialized: true,
error: undefined,
});
dispatch(setOnlineStatus({ isOnline: true }));
if (onConnected) {
onConnected();
}
} catch (error) {
console.error('[CRDT] Failed to connect to Live channel:', error);
setStatus({
isConnected: false,
isInitialized: true,
error: error instanceof Error ? error : new Error(String(error)),
});
if (onError && error instanceof Error) {
onError(error);
}
}
};
connect();
return () => {
isSubscribed = false;
if (subscriptionRef.current) {
subscriptionRef.current.unsubscribe();
subscriptionRef.current = null;
}
channelAddressRef.current = null;
};
}, [mapUid, enabled, nodeId, dispatch, onError, onConnected, onDisconnected]);
// Broadcast pending operations
useEffect(() => {
if (!status.isConnected || !channelAddressRef.current || !pendingOperations || pendingOperations.length === 0) {
return;
}
const liveService = getGrafanaLiveSrv();
if (!liveService) {
console.error('[CRDT] Live service not available');
return;
}
const channelAddress = channelAddressRef.current;
// Broadcast each pending operation
for (const operation of pendingOperations) {
try {
// Mark as applied locally
appliedOpsRef.current.add(operation.operationId);
// Publish to channel
liveService.publish(channelAddress, operation).catch((error) => {
console.error('[CRDT] Failed to broadcast operation:', error);
});
} catch (error) {
console.error('[CRDT] Failed to broadcast operation:', error);
}
}
// Clear pending operations after broadcast
dispatch(clearPendingOperations());
}, [pendingOperations, status.isConnected, dispatch]);
return status;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,283 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { v4 as uuidv4 } from 'uuid';
import { generateExploreId } from 'app/core/utils/explore';
import {
CanvasViewport,
ExploreMapState,
initialExploreMapState,
PanelPosition,
SerializedExploreState,
UserCursor,
} from './types';
interface AddPanelPayload {
position?: Partial<PanelPosition>;
viewportSize?: { width: number; height: number };
/**
* Optional panel mode. Defaults to 'explore' for backward compatibility.
* Use 'traces-drilldown' to embed the Explore Traces drilldown app.
*/
mode?: 'explore' | 'traces-drilldown';
}
const exploreMapSlice = createSlice({
name: 'exploreMap',
initialState: initialExploreMapState,
reducers: {
addPanel: (state, action: PayloadAction<AddPanelPayload>) => {
const panelId = uuidv4();
const exploreId = generateExploreId();
const mode = action.payload.mode ?? 'explore';
// Calculate center of current viewport in canvas coordinates
const viewportSize = action.payload.viewportSize || { width: 1920, height: 1080 };
const canvasCenterX = (-state.viewport.panX + viewportSize.width / 2) / state.viewport.zoom;
const canvasCenterY = (-state.viewport.panY + viewportSize.height / 2) / state.viewport.zoom;
const panelWidth = 600;
const panelHeight = 400;
const offset = Object.keys(state.panels).length * 30;
const defaultPosition: PanelPosition = {
x: canvasCenterX - panelWidth / 2 + offset,
y: canvasCenterY - panelHeight / 2 + offset,
width: panelWidth,
height: panelHeight,
zIndex: state.nextZIndex,
};
state.panels[panelId] = {
id: panelId,
exploreId: exploreId,
mode,
position: { ...defaultPosition, ...action.payload.position },
};
state.nextZIndex++;
state.selectedPanelIds = [panelId];
},
removePanel: (state, action: PayloadAction<{ panelId: string }>) => {
delete state.panels[action.payload.panelId];
state.selectedPanelIds = state.selectedPanelIds.filter((id) => id !== action.payload.panelId);
},
updatePanelPosition: (
state,
action: PayloadAction<{ panelId: string; position: Partial<PanelPosition & { iframeUrl?: string }> }>
) => {
const panel = state.panels[action.payload.panelId];
if (panel) {
// Handle position updates
const { iframeUrl, ...positionUpdates } = action.payload.position;
panel.position = { ...panel.position, ...positionUpdates };
// Handle iframe URL updates separately
if (iframeUrl !== undefined) {
panel.iframeUrl = iframeUrl;
}
}
},
updateMultiplePanelPositions: (
state,
action: PayloadAction<{ panelId: string; deltaX: number; deltaY: number }>
) => {
const { panelId, deltaX, deltaY } = action.payload;
// If the dragged panel is selected, move all selected panels EXCEPT the dragged one
// (the dragged panel is controlled by react-rnd)
if (state.selectedPanelIds.includes(panelId)) {
state.selectedPanelIds.forEach((id) => {
// Skip the panel being dragged - react-rnd controls it
if (id === panelId) {
return;
}
const panel = state.panels[id];
if (panel) {
panel.position.x += deltaX;
panel.position.y += deltaY;
}
});
} else {
// If dragging a non-selected panel, just move that one
const panel = state.panels[panelId];
if (panel) {
panel.position.x += deltaX;
panel.position.y += deltaY;
}
}
},
bringPanelToFront: (state, action: PayloadAction<{ panelId: string }>) => {
const panel = state.panels[action.payload.panelId];
if (panel) {
panel.position.zIndex = state.nextZIndex;
state.nextZIndex++;
}
},
selectPanel: (state, action: PayloadAction<{ panelId?: string; addToSelection?: boolean }>) => {
const { panelId, addToSelection } = action.payload;
if (!panelId) {
// Clear selection
state.selectedPanelIds = [];
return;
}
if (addToSelection) {
// Toggle panel in selection
if (state.selectedPanelIds.includes(panelId)) {
state.selectedPanelIds = state.selectedPanelIds.filter((id) => id !== panelId);
} else {
state.selectedPanelIds.push(panelId);
}
} else {
// Single selection
state.selectedPanelIds = [panelId];
}
// Bring all selected panels to front
state.selectedPanelIds.forEach((id) => {
const panel = state.panels[id];
if (panel) {
panel.position.zIndex = state.nextZIndex;
state.nextZIndex++;
}
});
},
selectMultiplePanels: (state, action: PayloadAction<{ panelIds: string[]; addToSelection?: boolean }>) => {
const { panelIds, addToSelection } = action.payload;
if (addToSelection) {
// Add to existing selection (dedupe)
const newIds = panelIds.filter((id) => !state.selectedPanelIds.includes(id));
state.selectedPanelIds = [...state.selectedPanelIds, ...newIds];
} else {
// Replace selection
state.selectedPanelIds = panelIds;
}
// Bring all selected panels to front
state.selectedPanelIds.forEach((id) => {
const panel = state.panels[id];
if (panel) {
panel.position.zIndex = state.nextZIndex;
state.nextZIndex++;
}
});
},
updateViewport: (state, action: PayloadAction<Partial<CanvasViewport>>) => {
state.viewport = { ...state.viewport, ...action.payload };
},
resetCanvas: (state) => {
state.panels = {};
state.selectedPanelIds = [];
state.nextZIndex = 1;
state.viewport = initialExploreMapState.viewport;
},
duplicatePanel: (state, action: PayloadAction<{ panelId: string }>) => {
const sourcePanel = state.panels[action.payload.panelId];
if (sourcePanel) {
const newPanelId = uuidv4();
const newExploreId = generateExploreId();
state.panels[newPanelId] = {
id: newPanelId,
exploreId: newExploreId,
mode: sourcePanel.mode ?? 'explore',
position: {
...sourcePanel.position,
x: sourcePanel.position.x + 30,
y: sourcePanel.position.y + 30,
zIndex: state.nextZIndex,
},
exploreState: sourcePanel.exploreState,
};
state.nextZIndex++;
state.selectedPanelIds = [newPanelId];
}
},
savePanelExploreState: (
state,
action: PayloadAction<{ panelId: string; exploreState: SerializedExploreState }>
) => {
const panel = state.panels[action.payload.panelId];
if (panel) {
panel.exploreState = action.payload.exploreState;
}
},
loadCanvas: (state, action: PayloadAction<ExploreMapState>) => {
const loadedState = action.payload;
// Merge with initial state to ensure we always have required defaults
// and to avoid persisting ephemeral state like selection and cursors.
return {
...initialExploreMapState,
...loadedState,
panels: Object.fromEntries(
Object.entries(loadedState.panels || {}).map(([panelId, panel]) => [
panelId,
{
...panel,
// Default to 'explore' for canvases saved before panel modes existed
mode: panel.mode ?? 'explore',
},
])
),
selectedPanelIds: [],
cursors: {},
};
},
updateCursor: (state, action: PayloadAction<UserCursor>) => {
state.cursors[action.payload.userId] = action.payload;
},
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;
},
},
});
export const {
addPanel,
removePanel,
updatePanelPosition,
updateMultiplePanelPositions,
bringPanelToFront,
selectPanel,
selectMultiplePanels,
updateViewport,
resetCanvas,
duplicatePanel,
savePanelExploreState,
loadCanvas,
updateCursor,
removeCursor,
setMapMetadata,
updateMapTitle,
clearMap,
} = exploreMapSlice.actions;
export const exploreMapReducer = exploreMapSlice.reducer;

View File

@@ -0,0 +1,260 @@
/**
* Redux middleware for CRDT operations
*
* This middleware intercepts actions that modify state and:
* 1. Broadcasts pending operations to the WebSocket channel
* 2. Handles incoming remote operations
* 3. Manages operation queue and deduplication
*/
import { Middleware } from '@reduxjs/toolkit';
import { CRDTOperation } from '../crdt/types';
import { selectPendingOperations } from './selectors';
import { clearPendingOperations } from './crdtSlice';
export interface OperationBroadcaster {
/**
* Broadcast an operation to other clients
*/
broadcast(operation: CRDTOperation): void;
/**
* Broadcast multiple operations
*/
broadcastBatch(operations: CRDTOperation[]): void;
/**
* Subscribe to incoming operations
*/
subscribe(handler: (operation: CRDTOperation) => void): () => void;
}
/**
* Create CRDT operation middleware
*
* @param broadcaster - Interface for broadcasting operations (WebSocket, etc.)
*/
export function createOperationMiddleware(
broadcaster?: OperationBroadcaster
): Middleware {
return (store) => (next) => (action) => {
// Apply action first
const result = next(action);
// After state update, check for pending operations to broadcast
if (broadcaster && shouldBroadcast((action as any).type)) {
const state = store.getState().exploreMapCRDT;
const pendingOps = selectPendingOperations(state);
if (pendingOps.length > 0) {
// Broadcast all pending operations
for (const op of pendingOps) {
try {
broadcaster.broadcast(op);
} catch (error) {
console.error('Failed to broadcast operation:', error);
}
}
// Clear pending operations after successful broadcast
store.dispatch(clearPendingOperations());
}
}
return result;
};
}
/**
* Determine if an action should trigger broadcasting
*/
function shouldBroadcast(actionType: string): boolean {
const broadcastableActions = [
'exploreMapCRDT/addPanel',
'exploreMapCRDT/removePanel',
'exploreMapCRDT/updatePanelPosition',
'exploreMapCRDT/updatePanelSize',
'exploreMapCRDT/bringPanelToFront',
'exploreMapCRDT/savePanelExploreState',
'exploreMapCRDT/updateMapTitle',
'exploreMapCRDT/duplicatePanel',
];
return broadcastableActions.includes(actionType);
}
/**
* Mock broadcaster for testing without WebSocket
*/
export class MockBroadcaster implements OperationBroadcaster {
private handlers: Array<(operation: CRDTOperation) => void> = [];
public broadcastedOperations: CRDTOperation[] = [];
broadcast(operation: CRDTOperation): void {
this.broadcastedOperations.push(operation);
}
broadcastBatch(operations: CRDTOperation[]): void {
this.broadcastedOperations.push(...operations);
}
subscribe(handler: (operation: CRDTOperation) => void): () => void {
this.handlers.push(handler);
// Return unsubscribe function
return () => {
const index = this.handlers.indexOf(handler);
if (index > -1) {
this.handlers.splice(index, 1);
}
};
}
/**
* Simulate receiving a remote operation
*/
simulateRemoteOperation(operation: CRDTOperation): void {
for (const handler of this.handlers) {
handler(operation);
}
}
/**
* Clear broadcasted operations history
*/
clear(): void {
this.broadcastedOperations = [];
}
}
/**
* Throttle helper for rate-limiting broadcasts
*/
export class ThrottledBroadcaster implements OperationBroadcaster {
private broadcaster: OperationBroadcaster;
private throttleMs: number;
private pendingBatch: CRDTOperation[] = [];
private timeoutId?: ReturnType<typeof setTimeout>;
constructor(broadcaster: OperationBroadcaster, throttleMs: number = 100) {
this.broadcaster = broadcaster;
this.throttleMs = throttleMs;
}
broadcast(operation: CRDTOperation): void {
this.pendingBatch.push(operation);
if (!this.timeoutId) {
this.timeoutId = setTimeout(() => {
this.flush();
}, this.throttleMs);
}
}
broadcastBatch(operations: CRDTOperation[]): void {
this.pendingBatch.push(...operations);
if (!this.timeoutId) {
this.timeoutId = setTimeout(() => {
this.flush();
}, this.throttleMs);
}
}
subscribe(handler: (operation: CRDTOperation) => void): () => void {
return this.broadcaster.subscribe(handler);
}
private flush(): void {
if (this.pendingBatch.length > 0) {
this.broadcaster.broadcastBatch([...this.pendingBatch]);
this.pendingBatch = [];
}
this.timeoutId = undefined;
}
/**
* Immediately flush any pending operations
*/
flushNow(): void {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
this.flush();
}
}
/**
* Buffered broadcaster for offline support
* Buffers operations when offline and replays when back online
*/
export class BufferedBroadcaster implements OperationBroadcaster {
private broadcaster: OperationBroadcaster;
private buffer: CRDTOperation[] = [];
private isOnline: boolean = true;
private maxBufferSize: number;
constructor(broadcaster: OperationBroadcaster, maxBufferSize: number = 1000) {
this.broadcaster = broadcaster;
this.maxBufferSize = maxBufferSize;
}
broadcast(operation: CRDTOperation): void {
if (this.isOnline) {
this.broadcaster.broadcast(operation);
} else {
this.bufferOperation(operation);
}
}
broadcastBatch(operations: CRDTOperation[]): void {
if (this.isOnline) {
this.broadcaster.broadcastBatch(operations);
} else {
operations.forEach((op) => this.bufferOperation(op));
}
}
subscribe(handler: (operation: CRDTOperation) => void): () => void {
return this.broadcaster.subscribe(handler);
}
private bufferOperation(operation: CRDTOperation): void {
this.buffer.push(operation);
// Limit buffer size
if (this.buffer.length > this.maxBufferSize) {
this.buffer.shift(); // Remove oldest
}
}
/**
* Set online status
* When going online, replays buffered operations
*/
setOnline(online: boolean): void {
const wasOffline = !this.isOnline;
this.isOnline = online;
if (online && wasOffline && this.buffer.length > 0) {
// Replay buffered operations
this.broadcaster.broadcastBatch([...this.buffer]);
this.buffer = [];
}
}
/**
* Get current buffer size
*/
getBufferSize(): number {
return this.buffer.length;
}
/**
* Clear buffer
*/
clearBuffer(): void {
this.buffer = [];
}
}

View File

@@ -0,0 +1,7 @@
import { exploreMapReducer } from './exploreMapSlice';
import { crdtReducer } from './crdtSlice';
export default {
exploreMap: exploreMapReducer,
exploreMapCRDT: crdtReducer,
};

View File

@@ -0,0 +1,576 @@
/**
* Redux selectors for CRDT-based Explore Map state
*
* These selectors convert CRDT state into UI-friendly formats
* for React components to consume.
*/
import { createSelector } from '@reduxjs/toolkit';
import { dateTime } from '@grafana/data';
import { CRDTStateManager } from '../crdt/state';
import { ExploreMapCRDTState } from './crdtSlice';
import { ExploreMapFrame, ExploreMapPanel } from './types';
/**
* Get CRDT manager from state
*/
function getCRDTManager(state: ExploreMapCRDTState): CRDTStateManager {
if (!state.crdtStateJSON) {
return new CRDTStateManager(state.uid || '', state.nodeId);
}
const json = JSON.parse(state.crdtStateJSON);
return CRDTStateManager.fromJSON(json, state.nodeId);
}
/**
* Cache for individual panel objects
* Key: panelId, Value: { panel object, serialized panel data for comparison }
*/
const panelCache = new Map<string, { panel: ExploreMapPanel; data: string }>();
/**
* Cache for individual frame objects
* Key: frameId, Value: { frame object, serialized frame data for comparison }
*/
const frameCache = new Map<string, { frame: ExploreMapFrame; data: string }>();
/**
* Cache for individual post-it note objects
* Key: postItId, Value: { postIt object, serialized postIt data for comparison }
*/
const postItCache = new Map<string, { postIt: any; data: string }>();
/**
* Select all panels as a Record (for compatibility with existing UI)
*
* OPTIMIZATION: This selector caches individual panel objects and only creates
* new panel objects when the underlying CRDT data for that specific panel changes.
* This prevents unnecessary re-renders of unchanged panels when another panel is modified.
*/
export const selectPanels = createSelector(
[
(state: ExploreMapCRDTState) => state.crdtStateJSON,
(state: ExploreMapCRDTState) => state.nodeId,
(state: ExploreMapCRDTState) => state.uid,
],
(crdtStateJSON, nodeId, uid): Record<string, ExploreMapPanel> => {
const state: ExploreMapCRDTState = {
uid,
crdtStateJSON,
nodeId,
sessionId: '', // Not needed for panel selection
pendingOperations: [],
local: {
viewport: { zoom: 1, panX: 0, panY: 0 },
selectedPanelIds: [],
cursors: {},
cursorMode: 'pointer',
isOnline: false,
isSyncing: false,
globalTimeRange: { from: dateTime().subtract(1, 'hour'), to: dateTime(), raw: { from: 'now-1h', to: 'now' } },
},
};
const manager = getCRDTManager(state);
const panels: Record<string, ExploreMapPanel> = {};
const currentPanelIds = new Set(manager.getPanelIds());
// Clean up cache for removed panels
for (const cachedPanelId of panelCache.keys()) {
if (!currentPanelIds.has(cachedPanelId)) {
panelCache.delete(cachedPanelId);
}
}
// Build panels object, reusing cached objects when data hasn't changed
for (const panelId of currentPanelIds) {
const panelData = manager.getPanelForUI(panelId);
if (!panelData) {
continue;
}
// Serialize the panel data to detect changes
const serializedData = JSON.stringify(panelData);
const cached = panelCache.get(panelId);
// Reuse cached panel if data hasn't changed
if (cached && cached.data === serializedData) {
panels[panelId] = cached.panel;
} else {
// Panel is new or changed, create new object and cache it
const panel = panelData as ExploreMapPanel;
panels[panelId] = panel;
panelCache.set(panelId, { panel, data: serializedData });
}
}
return panels;
}
);
/**
* Select a single panel by ID
*/
export const selectPanel = createSelector(
[
(state: ExploreMapCRDTState) => state,
(_state: ExploreMapCRDTState, panelId: string) => panelId,
],
(state, panelId): ExploreMapPanel | undefined => {
const manager = getCRDTManager(state);
const panelData = manager.getPanelForUI(panelId);
return panelData ? (panelData as ExploreMapPanel) : undefined;
}
);
/**
* Select panel IDs
*/
export const selectPanelIds = createSelector(
[(state: ExploreMapCRDTState) => state],
(state): string[] => {
const manager = getCRDTManager(state);
return manager.getPanelIds();
}
);
/**
* Select all frames as a Record (for compatibility with existing UI)
*
* OPTIMIZATION: This selector caches individual frame objects and only creates
* new frame objects when the underlying CRDT data for that specific frame changes.
* This prevents unnecessary re-renders of unchanged frames when another frame is modified.
*/
export const selectFrames = createSelector(
[
(state: ExploreMapCRDTState) => state.crdtStateJSON,
(state: ExploreMapCRDTState) => state.nodeId,
(state: ExploreMapCRDTState) => state.uid,
],
(crdtStateJSON, nodeId, uid): Record<string, ExploreMapFrame> => {
const state: ExploreMapCRDTState = {
uid,
crdtStateJSON,
nodeId,
sessionId: '', // Not needed for frame selection
pendingOperations: [],
local: {
viewport: { zoom: 1, panX: 0, panY: 0 },
selectedPanelIds: [],
cursors: {},
cursorMode: 'pointer',
isOnline: false,
isSyncing: false,
globalTimeRange: { from: dateTime().subtract(1, 'hour'), to: dateTime(), raw: { from: 'now-1h', to: 'now' } },
},
};
const manager = getCRDTManager(state);
const frames: Record<string, ExploreMapFrame> = {};
const currentFrameIds = new Set(manager.getFrameIds());
// Clean up cache for removed frames
for (const cachedFrameId of frameCache.keys()) {
if (!currentFrameIds.has(cachedFrameId)) {
frameCache.delete(cachedFrameId);
}
}
// Build frames object, reusing cached objects when data hasn't changed
for (const frameId of currentFrameIds) {
const frameData = manager.getFrameForUI(frameId);
if (!frameData) {
continue;
}
// Serialize the frame data to detect changes
const serializedData = JSON.stringify(frameData);
const cached = frameCache.get(frameId);
// Reuse cached frame if data hasn't changed
if (cached && cached.data === serializedData) {
frames[frameId] = cached.frame;
} else {
// Frame is new or changed, create new object and cache it
const frame = frameData as ExploreMapFrame;
frames[frameId] = frame;
frameCache.set(frameId, { frame, data: serializedData });
}
}
return frames;
}
);
/**
* Select a single frame by ID
*/
export const selectFrame = createSelector(
[
(state: ExploreMapCRDTState) => state,
(_state: ExploreMapCRDTState, frameId: string) => frameId,
],
(state, frameId): ExploreMapFrame | undefined => {
const manager = getCRDTManager(state);
const frameData = manager.getFrameForUI(frameId);
return frameData ? (frameData as ExploreMapFrame) : undefined;
}
);
/**
* Select frame IDs
*/
export const selectFrameIds = createSelector(
[(state: ExploreMapCRDTState) => state],
(state): string[] => {
const manager = getCRDTManager(state);
return manager.getFrameIds();
}
);
/**
* Select all panels in a specific frame
*/
export const selectPanelsInFrame = createSelector(
[
(state: ExploreMapCRDTState) => state,
(_state: ExploreMapCRDTState, frameId: string) => frameId,
],
(state, frameId): string[] => {
const manager = getCRDTManager(state);
return manager.getPanelsInFrame(frameId);
}
);
/**
* Select map title
*/
export const selectMapTitle = createSelector(
[(state: ExploreMapCRDTState) => state],
(state): string => {
const manager = getCRDTManager(state);
const crdtState = manager.getState();
return crdtState.title.get();
}
);
/**
* Select all comments as an array, sorted by timestamp (newest first)
*/
export const selectComments = createSelector(
[(state: ExploreMapCRDTState) => state],
(state) => {
const manager = getCRDTManager(state);
return manager.getCommentsForUI();
}
);
/**
* Select all post-it notes as a Record
*
* OPTIMIZATION: This selector caches individual post-it note objects and only creates
* new post-it note objects when the underlying CRDT data for that specific post-it note changes.
* This prevents unnecessary re-renders of unchanged post-it notes when another post-it note is modified.
*/
export const selectPostItNotes = createSelector(
[
(state: ExploreMapCRDTState) => state.crdtStateJSON,
(state: ExploreMapCRDTState) => state.nodeId,
(state: ExploreMapCRDTState) => state.uid,
],
(crdtStateJSON, nodeId, uid): Record<string, any> => {
const state: ExploreMapCRDTState = {
uid,
crdtStateJSON,
nodeId,
sessionId: '', // Not needed for post-it note selection
pendingOperations: [],
local: {
viewport: { zoom: 1, panX: 0, panY: 0 },
selectedPanelIds: [],
cursors: {},
cursorMode: 'pointer',
isOnline: false,
isSyncing: false,
globalTimeRange: { from: dateTime().subtract(1, 'hour'), to: dateTime(), raw: { from: 'now-1h', to: 'now' } },
},
};
const manager = getCRDTManager(state);
const postItNotes: Record<string, any> = {};
const currentPostItIds = new Set(manager.getPostItNoteIds());
// Clean up cache for removed post-it notes
for (const cachedPostItId of postItCache.keys()) {
if (!currentPostItIds.has(cachedPostItId)) {
postItCache.delete(cachedPostItId);
}
}
// Build post-it notes object, reusing cached objects when data hasn't changed
for (const postItId of currentPostItIds) {
const postItData = manager.getPostItNoteForUI(postItId);
if (!postItData) {
continue;
}
// Serialize the post-it note data to detect changes
const serializedData = JSON.stringify(postItData);
const cached = postItCache.get(postItId);
// Reuse cached post-it note if data hasn't changed
if (cached && cached.data === serializedData) {
postItNotes[postItId] = cached.postIt;
} else {
// Post-it note is new or changed, create new object and cache it
postItNotes[postItId] = postItData;
postItCache.set(postItId, { postIt: postItData, data: serializedData });
}
}
return postItNotes;
}
);
/**
* Select map UID
*/
export const selectMapUid = (state: ExploreMapCRDTState): string | undefined => {
return state.uid;
};
/**
* Select viewport
*/
export const selectViewport = (state: ExploreMapCRDTState) => {
return state.local.viewport;
};
/**
* Select selected panel IDs
*/
export const selectSelectedPanelIds = (state: ExploreMapCRDTState): string[] => {
return state.local.selectedPanelIds;
};
/**
* Select cursors
*/
export const selectCursors = (state: ExploreMapCRDTState) => {
return state.local.cursors;
};
/**
* Select cursor mode
*/
export const selectCursorMode = (state: ExploreMapCRDTState) => {
return state.local.cursorMode;
};
/**
* Select online status
*/
export const selectIsOnline = (state: ExploreMapCRDTState): boolean => {
return state.local.isOnline;
};
/**
* Select syncing status
*/
export const selectIsSyncing = (state: ExploreMapCRDTState): boolean => {
return state.local.isSyncing;
};
/**
* Select pending operations
*/
export const selectPendingOperations = (state: ExploreMapCRDTState) => {
return state.pendingOperations;
};
/**
* Select whether there are pending operations to broadcast
*/
export const selectHasPendingOperations = (state: ExploreMapCRDTState): boolean => {
return state.pendingOperations.length > 0;
};
/**
* Select node ID
*/
export const selectNodeId = (state: ExploreMapCRDTState): string => {
return state.nodeId;
};
/**
* Select session ID
*/
export const selectSessionId = (state: ExploreMapCRDTState): string => {
return state.sessionId;
};
/**
* Select clipboard state
*/
export const selectClipboard = (state: ExploreMapCRDTState) => {
return state.local.clipboard;
};
/**
* Select panel count
*/
export const selectPanelCount = createSelector(
[(state: ExploreMapCRDTState) => state],
(state): number => {
const manager = getCRDTManager(state);
return manager.getPanelIds().length;
}
);
/**
* Select selected panels
*/
export const selectSelectedPanels = createSelector(
[selectPanels, selectSelectedPanelIds],
(panels, selectedIds): ExploreMapPanel[] => {
return selectedIds.map((id) => panels[id]).filter(Boolean);
}
);
/**
* Check if a panel is selected
*/
export const selectIsPanelSelected = createSelector(
[
selectSelectedPanelIds,
(_state: ExploreMapCRDTState, panelId: string) => panelId,
],
(selectedIds, panelId): boolean => {
return selectedIds.includes(panelId);
}
);
/**
* Get the highest z-index (for bringing panels to front)
*/
export const selectMaxZIndex = createSelector(
[selectPanels],
(panels): number => {
let max = 0;
for (const panel of Object.values(panels)) {
if (panel.position.zIndex > max) {
max = panel.position.zIndex;
}
}
return max;
}
);
/**
* Get bounding box of all selected panels
*/
export const selectSelectedPanelsBounds = createSelector(
[selectSelectedPanels],
(panels): { minX: number; minY: number; maxX: number; maxY: number } | null => {
if (panels.length === 0) {
return null;
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const panel of panels) {
const { x, y, width, height } = panel.position;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
}
return { minX, minY, maxX, maxY };
}
);
/**
* Select entire CRDT state as JSON (for persistence)
*/
export const selectCRDTStateJSON = (state: ExploreMapCRDTState): string | undefined => {
return state.crdtStateJSON;
};
/**
* Select active users from cursors
* Groups by userId and filters out stale cursors (not updated in last 15 minutes)
*/
export const selectActiveUsers = createSelector(
[selectCursors],
(cursors) => {
const now = Date.now();
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
// Group cursors by userId and keep the most recent one for each user
const userMap = new Map<string, { userId: string; userName: string; lastUpdated: number }>();
for (const cursor of Object.values(cursors)) {
// Filter out stale cursors
if (now - cursor.lastUpdated > STALE_THRESHOLD_MS) {
continue;
}
// Keep the most recent cursor for each user
const existing = userMap.get(cursor.userId);
if (!existing || cursor.lastUpdated > existing.lastUpdated) {
userMap.set(cursor.userId, {
userId: cursor.userId,
userName: cursor.userName,
lastUpdated: cursor.lastUpdated,
});
}
}
// Convert to UserView format for UsersIndicator component
return Array.from(userMap.values())
.map((user) => ({
user: {
name: user.userName,
},
lastActiveAt: new Date(user.lastUpdated).toISOString(),
}))
.sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime());
}
);
/**
* Select the entire legacy-compatible state
* Useful for gradual migration
*/
export const selectLegacyState = createSelector(
[
selectPanels,
selectFrames,
selectMapTitle,
selectViewport,
selectSelectedPanelIds,
selectCursors,
selectMapUid,
(state: ExploreMapCRDTState) => state,
],
(panels, frames, title, viewport, selectedPanelIds, cursors, uid, state) => {
const manager = getCRDTManager(state);
const crdtState = manager.getState();
return {
uid,
title,
viewport,
panels,
frames,
selectedPanelIds,
nextZIndex: crdtState.zIndexCounter.value() + 1,
cursors,
};
}
);

View File

@@ -0,0 +1,112 @@
import { DataQuery, TimeRange, ExplorePanelsState } from '@grafana/data';
export interface PanelPosition {
x: number;
y: number;
width: number;
height: number;
zIndex: number;
}
export interface SerializedExploreState {
queries: DataQuery[];
datasourceUid?: string;
range: TimeRange;
refreshInterval?: string;
panelsState?: ExplorePanelsState;
compact?: boolean;
}
export interface ExploreMapPanel {
id: string;
exploreId: string;
position: PanelPosition;
exploreState?: SerializedExploreState;
remoteVersion?: number; // Increments only on remote explore state updates
/**
* Panel mode determines what kind of content is rendered inside the panel.
* - 'explore': standard Explore pane (current behavior)
* - 'traces-drilldown': Traces Drilldown app
* - 'metrics-drilldown': Metrics Drilldown app
* - 'profiles-drilldown': Profiles Drilldown app
* - 'logs-drilldown': Logs Drilldown app
*/
mode: 'explore' | 'traces-drilldown' | 'metrics-drilldown' | 'profiles-drilldown' | 'logs-drilldown';
/**
* For iframe-based panels (like traces-drilldown), store the complete URL
* including query parameters to restore state on reload
*/
iframeUrl?: string;
/**
* Username of the user who created this panel
*/
createdBy?: string;
/**
* Frame association properties
*/
frameId?: string; // Parent frame ID
frameOffsetX?: number; // Offset from frame origin
frameOffsetY?: number; // Offset from frame origin
}
export interface ExploreMapFrame {
id: string;
title: string;
position: PanelPosition; // Reuse position type
createdBy?: string;
remoteVersion?: number;
color?: string; // Frame border color (hex color string)
emoji?: string; // Emoji to display on frame
}
export interface CanvasViewport {
zoom: number;
panX: number;
panY: number;
}
export interface UserCursor {
userId: string;
sessionId: string;
userName: string;
color: string;
x: number;
y: number;
lastUpdated: number;
selectedPanelIds: string[];
}
export type CursorMode = 'pointer' | 'hand';
export interface ExploreMapState {
uid?: string;
title?: string;
viewport: CanvasViewport;
panels: Record<string, ExploreMapPanel>;
frames: Record<string, ExploreMapFrame>;
selectedPanelIds: string[];
nextZIndex: number;
cursors: Record<string, UserCursor>;
cursorMode: CursorMode;
crdtState?: any; // Raw CRDT state JSON for proper sync
}
export const initialExploreMapState: ExploreMapState = {
uid: undefined,
title: undefined,
viewport: {
zoom: 1,
// Center the viewport at canvas center (5000, 5000)
// The pan values are offsets, so we need to calculate based on viewport size
// At zoom 1, we want canvas position 5000 to be at screen center
// Initial position assumes typical viewport of ~1920x1080
panX: -4040, // -(5000 - 1920/2) = -4040
panY: -4460, // -(5000 - 1080/2) = -4460
},
panels: {},
frames: {},
selectedPanelIds: [],
nextZIndex: 1,
cursors: {},
cursorMode: 'pointer',
};

View File

@@ -170,6 +170,26 @@ export function getAppRoutes(): RouteDescriptor[] {
: import(/* webpackChunkName: "explore-feature-toggle-page" */ 'app/features/explore/FeatureTogglePage')
),
},
{
path: '/atlas',
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: '/atlas/:uid',
pageClass: 'page-explore-map',
roles: () => contextSrv.evaluatePermission([AccessControlAction.DataSourcesExplore]),
component: SafeDynamicImport(() =>
config.exploreEnabled
? import(/* webpackChunkName: "explore-map" */ 'app/features/explore-map/ExploreMapPage')
: import(/* webpackChunkName: "explore-feature-toggle-page" */ 'app/features/explore/FeatureTogglePage')
),
},
{
path: '/drilldown',
component: () => <NavLandingPage navId="drilldown" />,

View File

@@ -7,6 +7,7 @@ import { allMiddleware as allApiClientMiddleware } from '@grafana/api-clients/rt
import { legacyAPI } from 'app/api/clients/legacy';
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
import { createOperationMiddleware } from 'app/features/explore-map/state/middleware';
import { StoreState } from 'app/types/store';
import { buildInitialState } from '../core/reducers/navModel';
@@ -45,6 +46,8 @@ export function configureStore(initialState?: Partial<StoreState>) {
browseDashboardsAPI.middleware,
legacyAPI.middleware,
...allApiClientMiddleware,
// CRDT operation middleware for Explore Maps
createOperationMiddleware(),
...extraMiddleware
),
devTools: process.env.NODE_ENV !== 'production',

View File

@@ -14005,6 +14005,13 @@ __metadata:
languageName: node
linkType: hard
"clsx@npm:^1.1.1":
version: 1.2.1
resolution: "clsx@npm:1.2.1"
checksum: 10/5ded6f61f15f1fa0350e691ccec43a28b12fb8e64c8e94715f2a937bc3722d4c3ed41d6e945c971fc4dcc2a7213a43323beaf2e1c28654af63ba70c9968a8643
languageName: node
linkType: hard
"clsx@npm:^2.0.0, clsx@npm:^2.1.1":
version: 2.1.1
resolution: "clsx@npm:2.1.1"
@@ -19489,6 +19496,7 @@ __metadata:
react-refresh: "npm:0.14.0"
react-resizable: "npm:3.0.5"
react-responsive-carousel: "npm:^3.2.23"
react-rnd: "npm:10.4.13"
react-router: "npm:5.3.4"
react-router-dom: "npm:5.3.4"
react-router-dom-v5-compat: "npm:^6.26.1"
@@ -19502,6 +19510,7 @@ __metadata:
react-virtualized-auto-sizer: "npm:1.0.26"
react-window: "npm:1.8.11"
react-window-infinite-loader: "npm:1.0.10"
react-zoom-pan-pinch: "npm:3.6.1"
reduce-reducers: "npm:^1.0.4"
redux: "npm:5.0.1"
redux-mock-store: "npm:1.5.5"
@@ -28122,6 +28131,16 @@ __metadata:
languageName: node
linkType: hard
"re-resizable@npm:6.10.0":
version: 6.10.0
resolution: "re-resizable@npm:6.10.0"
peerDependencies:
react: ^16.13.1 || ^17.0.0 || ^18.0.0
react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0
checksum: 10/303e582feffdfd3e491e2b51dc75c8641c726882e2d8e3ec249b2bc1d23bfffa73bd4a982ed5456bcab9ec25f52b5430e8c632f32647295ed773679691a961a2
languageName: node
linkType: hard
"re-resizable@npm:6.11.2":
version: 6.11.2
resolution: "re-resizable@npm:6.11.2"
@@ -28307,6 +28326,19 @@ __metadata:
languageName: node
linkType: hard
"react-draggable@npm:4.4.6":
version: 4.4.6
resolution: "react-draggable@npm:4.4.6"
dependencies:
clsx: "npm:^1.1.1"
prop-types: "npm:^15.8.1"
peerDependencies:
react: ">= 16.3.0"
react-dom: ">= 16.3.0"
checksum: 10/51b9ac7f913797fc1cebc30ae383f346883033c45eb91e9b0b92e9ebd224bb1545b4ae2391825b649b798cc711a38351a5f41be24d949c64c6703ebc24eba661
languageName: node
linkType: hard
"react-draggable@npm:4.5.0, react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.5":
version: 4.5.0
resolution: "react-draggable@npm:4.5.0"
@@ -28671,6 +28703,20 @@ __metadata:
languageName: node
linkType: hard
"react-rnd@npm:10.4.13":
version: 10.4.13
resolution: "react-rnd@npm:10.4.13"
dependencies:
re-resizable: "npm:6.10.0"
react-draggable: "npm:4.4.6"
tslib: "npm:2.6.2"
peerDependencies:
react: ">=16.3.0"
react-dom: ">=16.3.0"
checksum: 10/3a343d71117c37e105286eeee38e244d9dc62e0e4b2efa929128995056408cf7fda15f5100f66b8548370ae63f886b94c7851e862fd90dd0b543ec382f43c4f1
languageName: node
linkType: hard
"react-router-dom-v5-compat@npm:^6.26.1":
version: 6.26.1
resolution: "react-router-dom-v5-compat@npm:6.26.1"
@@ -28987,6 +29033,16 @@ __metadata:
languageName: node
linkType: hard
"react-zoom-pan-pinch@npm:3.6.1":
version: 3.6.1
resolution: "react-zoom-pan-pinch@npm:3.6.1"
peerDependencies:
react: "*"
react-dom: "*"
checksum: 10/9146aa5c427dd6d0c8a4ebe3db0c720718eef6262d1b4b36033ee433bc76a9c84e30ca91311211ab95446305d3e2813d9abc576d093efbf5562be984431896cb
languageName: node
linkType: hard
"react@npm:18.3.1":
version: 18.3.1
resolution: "react@npm:18.3.1"
@@ -32660,6 +32716,13 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:2.6.2":
version: 2.6.2
resolution: "tslib@npm:2.6.2"
checksum: 10/bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca
languageName: node
linkType: hard
"tslib@npm:2.6.3":
version: 2.6.3
resolution: "tslib@npm:2.6.3"