diff --git a/public/app/features/explore-map/ExploreMapListPage.tsx b/public/app/features/explore-map/ExploreMapListPage.tsx
index 531a6ed81e3..2389ca089a3 100644
--- a/public/app/features/explore-map/ExploreMapListPage.tsx
+++ b/public/app/features/explore-map/ExploreMapListPage.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @grafana/i18n/no-untranslated-strings */
import { css } from '@emotion/css';
import { useEffect, useState } from 'react';
@@ -9,6 +10,7 @@ import {
ErrorBoundaryAlert,
Input,
LoadingPlaceholder,
+ UsersIndicator,
useStyles2,
} from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext';
@@ -16,6 +18,7 @@ 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) {
@@ -171,33 +174,7 @@ export default function ExploreMapListPage(props: GrafanaRouteComponentProps) {
) : (
{filteredMaps.map((map) => (
-
-
-
{map.title}
-
-
- Updated {formatDate(map.updatedAt)}
-
-
-
-
-
-
-
+
setDeleteConfirmUid(map.uid)} formatDate={formatDate} />
))}
)}
@@ -222,6 +199,52 @@ export default function ExploreMapListPage(props: GrafanaRouteComponentProps) {
);
}
+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 (
+
+
+
{map.title}
+
+
+ Updated {formatDate(map.updatedAt)}
+
+ {activeUsers.length > 0 && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
const getStyles = (theme: GrafanaTheme2) => {
return {
pageWrapper: css({
@@ -303,6 +326,7 @@ const getStyles = (theme: GrafanaTheme2) => {
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,
@@ -333,6 +357,11 @@ const getStyles = (theme: GrafanaTheme2) => {
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),
diff --git a/public/app/features/explore-map/ExploreMapPage.tsx b/public/app/features/explore-map/ExploreMapPage.tsx
index ad89d488e48..844bf6f776e 100644
--- a/public/app/features/explore-map/ExploreMapPage.tsx
+++ b/public/app/features/explore-map/ExploreMapPage.tsx
@@ -17,6 +17,7 @@ import { ExploreMapFloatingToolbar } from './components/ExploreMapFloatingToolba
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
@@ -93,6 +94,10 @@ export default function ExploreMapPage(props: GrafanaRouteComponentProps<{ uid?:
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,
@@ -103,7 +108,7 @@ export default function ExploreMapPage(props: GrafanaRouteComponentProps<{ uid?:
return (
- Loading explore map...
+ Loading explore map...
);
diff --git a/public/app/features/explore-map/hooks/useMapActiveUsers.ts b/public/app/features/explore-map/hooks/useMapActiveUsers.ts
new file mode 100644
index 00000000000..68ba8fcb59b
--- /dev/null
+++ b/public/app/features/explore-map/hooks/useMapActiveUsers.ts
@@ -0,0 +1,280 @@
+/* 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 { useDispatch } from 'app/types/store';
+
+import { updateCursor, removeCursor } from '../state/crdtSlice';
+import { UserCursor } from '../state/types';
+
+interface CursorUpdateMessage {
+ type: 'cursor_update';
+ sessionId: string;
+ userId: string;
+ userName: string;
+ data: {
+ x: number;
+ y: number;
+ color: 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 => {
+ 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();
+
+ // 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) => {
+ try {
+ const key = getStorageKey(mapUid);
+ const data: Record = {};
+
+ 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([]);
+ const subscriptionRef = useRef(null);
+ // Track all users who have been active, even after they disconnect
+ const usersHistoryRef = useRef