Show currently active users in the worlds card list

This commit is contained in:
Joey
2025-12-03 10:55:36 +00:00
parent 23c6c48e9e
commit c048f25d67
3 changed files with 342 additions and 28 deletions
@@ -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) {
) : (
<div className={styles.mapGrid}>
{filteredMaps.map((map) => (
<div key={map.uid} 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>
</div>
</div>
<div className={styles.mapCardActions}>
<Button
variant="primary"
size="sm"
onClick={() => (window.location.href = `/explore-maps/${map.uid}`)}
>
Open
</Button>
<Button
variant="destructive"
fill="text"
size="sm"
icon="trash-alt"
onClick={() => setDeleteConfirmUid(map.uid)}
aria-label="Delete map"
/>
</div>
</div>
<MapCard key={map.uid} map={map} onDelete={() => setDeleteConfirmUid(map.uid)} formatDate={formatDate} />
))}
</div>
)}
@@ -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 (
<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 = `/explore-maps/${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({
@@ -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),
@@ -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 (
<div className={styles.loadingWrapper}>
<p>
<Trans i18nKey="explore-map.loading">Loading explore map...</Trans>
<Trans i18nKey="explore-map.page.loading">Loading explore map...</Trans>
</p>
</div>
);
@@ -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<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();
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,
};
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;
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,
};
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]);
return activeUsers;
}