Show currently active users in world in top bar

This commit is contained in:
Joey
2025-12-03 10:54:20 +00:00
parent 00ce9b57b5
commit 23c6c48e9e
2 changed files with 55 additions and 2 deletions
@@ -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) {
</div>
<div className={styles.toolbarSection}>
{activeUsers.length > 0 && (
<div className={styles.activeUsersContainer}>
<UsersIndicator users={activeUsers} limit={5} />
</div>
)}
<ButtonGroup>
<ToolbarButton
icon="save"
@@ -276,5 +282,10 @@ const getStyles = (theme: GrafanaTheme2) => {
color: theme.colors.text.secondary,
fontStyle: 'italic',
}),
activeUsersContainer: css({
display: 'flex',
alignItems: 'center',
marginRight: theme.spacing(2),
}),
};
};
@@ -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<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