diff --git a/public/app/features/explore-map/components/ExploreMapToolbar.tsx b/public/app/features/explore-map/components/ExploreMapToolbar.tsx
index b2173e96b11..9c50196584b 100644
--- a/public/app/features/explore-map/components/ExploreMapToolbar.tsx
+++ b/public/app/features/explore-map/components/ExploreMapToolbar.tsx
@@ -3,13 +3,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
-import { Button, ButtonGroup, ConfirmModal, Input, ToolbarButton, useStyles2 } from '@grafana/ui';
+import { Button, ButtonGroup, ConfirmModal, Input, ToolbarButton, UsersIndicator, useStyles2 } from '@grafana/ui';
import { useDispatch, useSelector } from 'app/types/store';
import { useTransformContext } from '../context/TransformContext';
import { useCanvasPersistence } from '../hooks/useCanvasPersistence';
import { updateMapTitle } from '../state/crdtSlice';
-import { selectPanelCount, selectViewport, selectMapTitle } from '../state/selectors';
+import { selectPanelCount, selectViewport, selectMapTitle, selectActiveUsers } from '../state/selectors';
interface ExploreMapToolbarProps {
uid?: string;
@@ -28,6 +28,7 @@ export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) {
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));
useEffect(() => {
if (mapTitle) {
@@ -185,6 +186,11 @@ export function ExploreMapToolbar({ uid }: ExploreMapToolbarProps) {
+ {activeUsers.length > 0 && (
+
+
+
+ )}
{
color: theme.colors.text.secondary,
fontStyle: 'italic',
}),
+ activeUsersContainer: css({
+ display: 'flex',
+ alignItems: 'center',
+ marginRight: theme.spacing(2),
+ }),
};
};
diff --git a/public/app/features/explore-map/state/selectors.ts b/public/app/features/explore-map/state/selectors.ts
index 629458262d2..6829277944f 100644
--- a/public/app/features/explore-map/state/selectors.ts
+++ b/public/app/features/explore-map/state/selectors.ts
@@ -298,6 +298,48 @@ export const selectCRDTStateJSON = (state: ExploreMapCRDTState): string | undefi
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();
+
+ 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