From 23c6c48e9e3b88c4f296de3e48147273be16c35b Mon Sep 17 00:00:00 2001 From: Joey Date: Tue, 2 Dec 2025 15:25:33 +0000 Subject: [PATCH] Show currently active users in world in top bar --- .../components/ExploreMapToolbar.tsx | 15 ++++++- .../features/explore-map/state/selectors.ts | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) 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