Compare commits
61 Commits
sriram/SQL
...
20251127_h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
270415c5bd | ||
|
|
b8e86bb46f | ||
|
|
12c04d55f6 | ||
|
|
efc5e97a74 | ||
|
|
022f8847da | ||
|
|
1628e1e0da | ||
|
|
93b91e4374 | ||
|
|
bcde82d955 | ||
|
|
07218f3417 | ||
|
|
32175192fb | ||
|
|
ccaf288016 | ||
|
|
dacd82c42c | ||
|
|
39ff7d4b20 | ||
|
|
fff727a632 | ||
|
|
fe8a7c052a | ||
|
|
4e2849f1a9 | ||
|
|
329da8298a | ||
|
|
91ba4bc419 | ||
|
|
66ed7e526a | ||
|
|
0b5a7b91fc | ||
|
|
cd0d3a8d0a | ||
|
|
34e031e40a | ||
|
|
c048f25d67 | ||
|
|
23c6c48e9e | ||
|
|
00ce9b57b5 | ||
|
|
1d1d8bcbdb | ||
|
|
29c5cb7b1c | ||
|
|
75bd64ff6b | ||
|
|
bf9e6eefd1 | ||
|
|
6692d08f7e | ||
|
|
d3869df7ce | ||
|
|
4579df3867 | ||
|
|
9b560f1413 | ||
|
|
5c49ba8e6a | ||
|
|
f500500927 | ||
|
|
ba65f18678 | ||
|
|
0fc79c4655 | ||
|
|
11d1cb54c2 | ||
|
|
ecfe2bd4b2 | ||
|
|
f6e547a7e4 | ||
|
|
b0c7930d6d | ||
|
|
b1b0af06e7 | ||
|
|
3111cc9283 | ||
|
|
b83b173c35 | ||
|
|
19a6441467 | ||
|
|
71526b2ab9 | ||
|
|
b9ae0c98b6 | ||
|
|
ac188c1fe1 | ||
|
|
32764bf74e | ||
|
|
61e8917605 | ||
|
|
1becc14b2d | ||
|
|
751e87a9e8 | ||
|
|
3af9cb8543 | ||
|
|
09d0deb180 | ||
|
|
ff28bcb0b2 | ||
|
|
dd23b87e4f | ||
|
|
9e6312bf61 | ||
|
|
0b31d6b0cf | ||
|
|
81738addac | ||
|
|
1e687c9d6c | ||
|
|
adbbe76dc7 |
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
228
pkg/api/exploremap.go
Normal 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"})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
9
pkg/server/wire_gen.go
generated
9
pkg/server/wire_gen.go
generated
File diff suppressed because one or more lines are too long
177
pkg/services/exploremap/crdt/hlc.go
Normal file
177
pkg/services/exploremap/crdt/hlc.go
Normal 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
|
||||
}
|
||||
82
pkg/services/exploremap/crdt/lwwregister.go
Normal file
82
pkg/services/exploremap/crdt/lwwregister.go
Normal 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
|
||||
}
|
||||
165
pkg/services/exploremap/crdt/operations.go
Normal file
165
pkg/services/exploremap/crdt/operations.go
Normal 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
|
||||
}
|
||||
}
|
||||
215
pkg/services/exploremap/crdt/orset.go
Normal file
215
pkg/services/exploremap/crdt/orset.go
Normal 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
|
||||
}
|
||||
134
pkg/services/exploremap/crdt/pncounter.go
Normal file
134
pkg/services/exploremap/crdt/pncounter.go
Normal 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
|
||||
}
|
||||
113
pkg/services/exploremap/exploremapimpl/service.go
Normal file
113
pkg/services/exploremap/exploremapimpl/service.go
Normal 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)
|
||||
}
|
||||
15
pkg/services/exploremap/exploremapimpl/store.go
Normal file
15
pkg/services/exploremap/exploremapimpl/store.go
Normal 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
|
||||
}
|
||||
156
pkg/services/exploremap/exploremapimpl/xorm_store.go
Normal file
156
pkg/services/exploremap/exploremapimpl/xorm_store.go
Normal 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
|
||||
}
|
||||
76
pkg/services/exploremap/model.go
Normal file
76
pkg/services/exploremap/model.go
Normal 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
|
||||
}
|
||||
150
pkg/services/exploremap/realtime/channels.go
Normal file
150
pkg/services/exploremap/realtime/channels.go
Normal 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 ""
|
||||
}
|
||||
253
pkg/services/exploremap/realtime/hub.go
Normal file
253
pkg/services/exploremap/realtime/hub.go
Normal 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)
|
||||
}
|
||||
13
pkg/services/exploremap/service.go
Normal file
13
pkg/services/exploremap/service.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
30
pkg/services/sqlstore/migrations/explore_map_mig.go
Normal file
30
pkg/services/sqlstore/migrations/explore_map_mig.go
Normal 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]))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
371
public/app/features/explore-map/ExploreMapListPage.tsx
Normal file
371
public/app/features/explore-map/ExploreMapListPage.tsx
Normal 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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
153
public/app/features/explore-map/ExploreMapPage.tsx
Normal file
153
public/app/features/explore-map/ExploreMapPage.tsx
Normal 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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
77
public/app/features/explore-map/api/exploreMapApi.ts
Normal file
77
public/app/features/explore-map/api/exploreMapApi.ts
Normal 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();
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 "{frameTitle}"?
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
436
public/app/features/explore-map/components/ExploreMapCanvas.tsx
Normal file
436
public/app/features/explore-map/components/ExploreMapCanvas.tsx
Normal 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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
408
public/app/features/explore-map/components/ExploreMapComment.tsx
Normal file
408
public/app/features/explore-map/components/ExploreMapComment.tsx
Normal 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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}),
|
||||
};
|
||||
};
|
||||
764
public/app/features/explore-map/components/ExploreMapFrame.tsx
Normal file
764
public/app/features/explore-map/components/ExploreMapFrame.tsx
Normal 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}`,
|
||||
}),
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
362
public/app/features/explore-map/components/ExploreMapToolbar.tsx
Normal file
362
public/app/features/explore-map/components/ExploreMapToolbar.tsx
Normal 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
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
187
public/app/features/explore-map/components/Minimap.tsx
Normal file
187
public/app/features/explore-map/components/Minimap.tsx
Normal 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',
|
||||
},
|
||||
}),
|
||||
});
|
||||
91
public/app/features/explore-map/components/UserCursor.tsx
Normal file
91
public/app/features/explore-map/components/UserCursor.tsx
Normal 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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
14
public/app/features/explore-map/context/TransformContext.tsx
Normal file
14
public/app/features/explore-map/context/TransformContext.tsx
Normal 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;
|
||||
158
public/app/features/explore-map/crdt/hlc.ts
Normal file
158
public/app/features/explore-map/crdt/hlc.ts
Normal 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);
|
||||
}
|
||||
44
public/app/features/explore-map/crdt/index.ts
Normal file
44
public/app/features/explore-map/crdt/index.ts
Normal 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';
|
||||
127
public/app/features/explore-map/crdt/lwwregister.ts
Normal file
127
public/app/features/explore-map/crdt/lwwregister.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
257
public/app/features/explore-map/crdt/orset.ts
Normal file
257
public/app/features/explore-map/crdt/orset.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
167
public/app/features/explore-map/crdt/pncounter.ts
Normal file
167
public/app/features/explore-map/crdt/pncounter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
1958
public/app/features/explore-map/crdt/state.ts
Normal file
1958
public/app/features/explore-map/crdt/state.ts
Normal file
File diff suppressed because it is too large
Load Diff
682
public/app/features/explore-map/crdt/types.ts
Normal file
682
public/app/features/explore-map/crdt/types.ts
Normal 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;
|
||||
}
|
||||
373
public/app/features/explore-map/hooks/useCanvasPersistence.ts
Normal file
373
public/app/features/explore-map/hooks/useCanvasPersistence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
274
public/app/features/explore-map/hooks/useCursorSync.ts
Normal file
274
public/app/features/explore-map/hooks/useCursorSync.ts
Normal 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)];
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
105
public/app/features/explore-map/hooks/useExploreStateReceiver.ts
Normal file
105
public/app/features/explore-map/hooks/useExploreStateReceiver.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
89
public/app/features/explore-map/hooks/useExploreStateSync.ts
Normal file
89
public/app/features/explore-map/hooks/useExploreStateSync.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
290
public/app/features/explore-map/hooks/useMapActiveUsers.ts
Normal file
290
public/app/features/explore-map/hooks/useMapActiveUsers.ts
Normal 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;
|
||||
}
|
||||
|
||||
112
public/app/features/explore-map/hooks/usePanelStateSync.ts
Normal file
112
public/app/features/explore-map/hooks/usePanelStateSync.ts
Normal 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]);
|
||||
}
|
||||
314
public/app/features/explore-map/operations/creators.ts
Normal file
314
public/app/features/explore-map/operations/creators.ts
Normal 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);
|
||||
}
|
||||
45
public/app/features/explore-map/operations/index.ts
Normal file
45
public/app/features/explore-map/operations/index.ts
Normal 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';
|
||||
288
public/app/features/explore-map/operations/queue.ts
Normal file
288
public/app/features/explore-map/operations/queue.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
320
public/app/features/explore-map/operations/serialization.ts
Normal file
320
public/app/features/explore-map/operations/serialization.ts
Normal 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;
|
||||
}
|
||||
443
public/app/features/explore-map/operations/validators.ts
Normal file
443
public/app/features/explore-map/operations/validators.ts
Normal 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
|
||||
);
|
||||
}
|
||||
223
public/app/features/explore-map/realtime/useRealtimeSync.ts
Normal file
223
public/app/features/explore-map/realtime/useRealtimeSync.ts
Normal 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;
|
||||
}
|
||||
1504
public/app/features/explore-map/state/crdtSlice.ts
Normal file
1504
public/app/features/explore-map/state/crdtSlice.ts
Normal file
File diff suppressed because it is too large
Load Diff
283
public/app/features/explore-map/state/exploreMapSlice.ts
Normal file
283
public/app/features/explore-map/state/exploreMapSlice.ts
Normal 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;
|
||||
260
public/app/features/explore-map/state/middleware.ts
Normal file
260
public/app/features/explore-map/state/middleware.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
7
public/app/features/explore-map/state/reducers.ts
Normal file
7
public/app/features/explore-map/state/reducers.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { exploreMapReducer } from './exploreMapSlice';
|
||||
import { crdtReducer } from './crdtSlice';
|
||||
|
||||
export default {
|
||||
exploreMap: exploreMapReducer,
|
||||
exploreMapCRDT: crdtReducer,
|
||||
};
|
||||
576
public/app/features/explore-map/state/selectors.ts
Normal file
576
public/app/features/explore-map/state/selectors.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
);
|
||||
112
public/app/features/explore-map/state/types.ts
Normal file
112
public/app/features/explore-map/state/types.ts
Normal 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',
|
||||
};
|
||||
@@ -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" />,
|
||||
|
||||
@@ -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',
|
||||
|
||||
63
yarn.lock
63
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user