fix(referencelines): and bring back the notification for worker updates but disallow duplication (#5005)

This commit is contained in:
Alireza 2025-04-29 14:53:37 -04:00 committed by GitHub
parent 4643d6dc40
commit 98f8187dfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 285 additions and 65 deletions

View File

@ -289,10 +289,16 @@ export default async function init({
eventTarget.addEventListenerDebounced( eventTarget.addEventListenerDebounced(
EVENTS.ERROR_EVENT, EVENTS.ERROR_EVENT,
({ detail }) => { ({ detail }) => {
// Create a stable ID for deduplication based on error type and message
const errorId = `cornerstone-error-${detail.type}-${detail.message.substring(0, 50)}`;
uiNotificationService.show({ uiNotificationService.show({
title: detail.type, title: detail.type,
message: detail.message, message: detail.message,
type: 'error', type: 'error',
id: errorId,
allowDuplicates: false, // Prevent duplicate error notifications
deduplicationInterval: 30000, // 30 seconds deduplication window
}); });
}, },
100 100
@ -310,37 +316,73 @@ export default async function init({
} }
function initializeWebWorkerProgressHandler(uiNotificationService) { function initializeWebWorkerProgressHandler(uiNotificationService) {
const activeToasts = new Map(); // Use a single map to track all active worker tasks
const activeWorkerTasks = new Map();
// eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => { // Create a normalized task key that doesn't include the random ID
// const { progress, type, id } = detail; // This helps us identify and deduplicate the same type of task
const getNormalizedTaskKey = type => {
return `worker-task-${type.toLowerCase().replace(/\s+/g, '-')}`;
};
// const cacheKey = `${type}-${id}`; eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => {
// if (progress === 0 && !activeToasts.has(cacheKey)) { const { progress, type, id } = detail;
// const progressPromise = new Promise((resolve, reject) => {
// activeToasts.set(cacheKey, { resolve, reject });
// });
// uiNotificationService.show({ // Skip notifications for compute statistics
// id: cacheKey, if (type === cornerstoneTools.Enums.WorkerTypes.COMPUTE_STATISTICS) {
// title: `${type}`, return;
// message: `${type}: ${progress}%`, }
// autoClose: false,
// promise: progressPromise, const normalizedKey = getNormalizedTaskKey(type);
// promiseMessages: {
// loading: `Computing...`, if (progress === 0) {
// success: `Completed successfully`, // Check if we're already tracking a task of this type
// error: 'Web Worker failed', if (!activeWorkerTasks.has(normalizedKey)) {
// }, const progressPromise = new Promise((resolve, reject) => {
// }); activeWorkerTasks.set(normalizedKey, {
// } else { resolve,
// if (progress === 100) { reject,
// const { resolve } = activeToasts.get(cacheKey); originalId: id,
// resolve({ progress, type }); type,
// activeToasts.delete(cacheKey); });
// } });
// }
// }); uiNotificationService.show({
id: normalizedKey, // Use the normalized key as ID for better deduplication
title: `${type}`,
message: `Computing...`,
autoClose: false,
allowDuplicates: false,
deduplicationInterval: 60000, // 60 seconds - prevent frequent notifications of same type
promise: progressPromise,
promiseMessages: {
loading: `Computing...`,
success: `Completed successfully`,
error: 'Web Worker failed',
},
});
} else {
// Already tracking this type of task, just let it continue
console.debug(`Already tracking a "${type}" task, skipping duplicate notification`);
}
}
// Task completed
else if (progress === 100) {
// Check if we have this task type in our tracking map
const taskData = activeWorkerTasks.get(normalizedKey);
if (taskData) {
// Resolve the promise to update the notification
const { resolve } = taskData;
resolve({ progress, type });
// Remove from tracking
activeWorkerTasks.delete(normalizedKey);
console.debug(`Worker task "${type}" completed successfully`);
}
}
});
} }
/** /**

View File

@ -48,6 +48,8 @@ import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
export default function initCornerstoneTools(configuration = {}) { export default function initCornerstoneTools(configuration = {}) {
CrosshairsTool.isAnnotation = false; CrosshairsTool.isAnnotation = false;
LabelmapSlicePropagationTool.isAnnotation = false;
MarkerLabelmapTool.isAnnotation = false;
ReferenceLinesTool.isAnnotation = false; ReferenceLinesTool.isAnnotation = false;
AdvancedMagnifyTool.isAnnotation = false; AdvancedMagnifyTool.isAnnotation = false;
PlanarFreehandContourSegmentationTool.isAnnotation = false; PlanarFreehandContourSegmentationTool.isAnnotation = false;

View File

@ -180,15 +180,12 @@ const CornerstoneViewportDownloadForm = ({
const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId); const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId);
toolGroup.addViewport(downloadViewportId, renderingEngineId); toolGroup.addViewport(downloadViewportId, renderingEngineId);
Object.keys(toolGroup.getToolInstances()).forEach(toolName => { const toolInstances = toolGroup.getToolInstances();
if (show && toolName !== 'Crosshairs') { const toolInstancesArray = Object.values(toolInstances);
try {
toolGroup.setToolEnabled(toolName); toolInstancesArray.forEach(toolInstance => {
} catch (error) { if (toolInstance.constructor.isAnnotation !== false) {
console.debug('Error enabling tool:', error); toolGroup.setToolEnabled(toolInstance.toolName);
}
} else {
toolGroup.setToolDisabled(toolName);
} }
}); });
}; };

View File

@ -4,7 +4,7 @@ const collapsedOutsideBorderSize = 4;
const collapsedWidth = 25; const collapsedWidth = 25;
const rightPanelInitialExpandedWidth = 280; const rightPanelInitialExpandedWidth = 280;
const leftPanelInitialExpandedWidth = 292; const leftPanelInitialExpandedWidth = 282;
const panelGroupDefinition = { const panelGroupDefinition = {
groupId: 'viewerLayoutResizablePanelGroup', groupId: 'viewerLayoutResizablePanelGroup',

View File

@ -120,8 +120,8 @@ const toolbarButtons: Button[] = [
tooltip: 'Show Reference Lines', tooltip: 'Show Reference Lines',
commands: 'toggleEnabledDisabledToolbar', commands: 'toggleEnabledDisabledToolbar',
listeners: { listeners: {
[ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: callbacks('ReferenceLinesTool'), [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: callbacks('ReferenceLines'),
[ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('ReferenceLinesTool'), [ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('ReferenceLines'),
}, },
evaluate: [ evaluate: [
'evaluate.cornerstoneTool.toggle', 'evaluate.cornerstoneTool.toggle',

View File

@ -60,17 +60,24 @@ class UINotificationService {
* @param {string} [notification.promiseMessages.loading] - Message to show while promise is pending * @param {string} [notification.promiseMessages.loading] - Message to show while promise is pending
* @param {string | function} [notification.promiseMessages.success] - Message to show when promise resolves * @param {string | function} [notification.promiseMessages.success] - Message to show when promise resolves
* @param {string | function} [notification.promiseMessages.error] - Message to show when promise rejects * @param {string | function} [notification.promiseMessages.error] - Message to show when promise rejects
* @param {object} [notification.action] - Action button configuration
* @param {string} notification.action.label - The label for the action button
* @param {function} notification.action.onClick - The function to call when the action button is clicked
* @returns {string} id - The ID of the created notification * @returns {string} id - The ID of the created notification
*/ */
show({ show({
title, title,
message, message,
duration = 5000, duration = 2000,
position = 'bottom-right', position = 'bottom-right',
type = 'info', type = 'info',
autoClose = true, autoClose = true,
promise, promise,
promiseMessages, promiseMessages,
id,
allowDuplicates = false,
deduplicationInterval = 30000,
action,
}: { }: {
title: string; title: string;
message: string | ((data?: any) => string); message: string | ((data?: any) => string);
@ -90,6 +97,13 @@ class UINotificationService {
success?: string | ((data: any) => string); success?: string | ((data: any) => string);
error?: string | ((error: any) => string); error?: string | ((error: any) => string);
}; };
id?: string;
allowDuplicates?: boolean;
deduplicationInterval?: number;
action?: {
label: string;
onClick: () => void;
};
}): string { }): string {
if (promise && promiseMessages) { if (promise && promiseMessages) {
const loadingId = serviceImplementation._show({ const loadingId = serviceImplementation._show({
@ -98,6 +112,9 @@ class UINotificationService {
type: 'loading', type: 'loading',
autoClose: false, autoClose: false,
position, position,
id: id ? `${id}-loading` : undefined,
allowDuplicates,
deduplicationInterval,
}); });
promise.then( promise.then(
@ -114,6 +131,10 @@ class UINotificationService {
duration, duration,
position, position,
autoClose, autoClose,
id: id ? `${id}-success` : undefined,
allowDuplicates,
deduplicationInterval,
action,
}); });
this.hide(loadingId); this.hide(loadingId);
}, },
@ -130,6 +151,10 @@ class UINotificationService {
duration, duration,
position, position,
autoClose, autoClose,
id: id ? `${id}-error` : undefined,
allowDuplicates,
deduplicationInterval,
action,
}); });
this.hide(loadingId); this.hide(loadingId);
} }
@ -145,6 +170,10 @@ class UINotificationService {
position, position,
type, type,
autoClose, autoClose,
id,
allowDuplicates,
deduplicationInterval,
action,
}); });
} }
} }

View File

@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary as ReactErrorBoundary, FallbackProps } from 'react-error-boundary';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Dialog, DialogContent } from '../Dialog/Dialog'; import { Dialog, DialogContent } from '../Dialog/Dialog';
import { ScrollArea } from '../ScrollArea/ScrollArea'; import { ScrollArea } from '../ScrollArea/ScrollArea';
import { Button } from '../Button/Button'; import { Button } from '../Button/Button';
import { useNotification } from '../../contextProviders';
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
@ -112,7 +112,7 @@ interface ErrorBoundaryError extends Error {
stack?: string; stack?: string;
} }
interface DefaultFallbackProps { interface DefaultFallbackProps extends FallbackProps {
error: ErrorBoundaryError; error: ErrorBoundaryError;
context: string; context: string;
resetErrorBoundary: () => void; resetErrorBoundary: () => void;
@ -135,6 +135,8 @@ const DefaultFallback = ({
}: DefaultFallbackProps) => { }: DefaultFallbackProps) => {
const { t } = useTranslation('ErrorBoundary'); const { t } = useTranslation('ErrorBoundary');
const [showDetails, setShowDetails] = useState(false); const [showDetails, setShowDetails] = useState(false);
const { show } = useNotification();
const title = `${t('Something went wrong')}${!isProduction && ` ${t('in')} ${context}`}.`; const title = `${t('Something went wrong')}${!isProduction && ` ${t('in')} ${context}`}.`;
const subtitle = t('Sorry, something went wrong there. Try again.'); const subtitle = t('Sorry, something went wrong there. Try again.');
@ -143,20 +145,32 @@ const DefaultFallback = ({
const copyErrorToClipboard = () => { const copyErrorToClipboard = () => {
if (code) { if (code) {
navigator.clipboard.writeText(code); navigator.clipboard.writeText(code);
toast.success(t('Error copied to clipboard')); show({
title: t('Success'),
message: t('Error copied to clipboard'),
type: 'success',
duration: 3000,
});
} }
}; };
useEffect(() => { useEffect(() => {
toast.error(title, { // Use a stable ID based on error message to support deduplication
description: subtitle, const errorId = `error-${errorTitle || error.message}`;
// We don't need to track shown state - instead rely on the notification deduplication system
show({
title,
message: subtitle,
type: 'error',
duration: 0,
id: errorId,
action: { action: {
label: t('Show Details'), label: t('Show Details'),
onClick: () => setShowDetails(true), onClick: () => setShowDetails(true),
}, },
duration: 0,
}); });
}, [error, subtitle, t, title]); }, [error, errorTitle, subtitle, t, title, show]);
if (isProduction) { if (isProduction) {
return null; return null;
@ -286,9 +300,8 @@ const ErrorBoundary = ({
<ReactErrorBoundary <ReactErrorBoundary
fallbackRender={props => ( fallbackRender={props => (
<FallbackComponent <FallbackComponent
error={props.error} {...props}
context={context} context={context}
resetErrorBoundary={props.resetErrorBoundary}
/> />
)} )}
onReset={onResetHandler} onReset={onResetHandler}

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useCallback, useEffect, ReactNode } from 'react'; import React, { createContext, useContext, useCallback, useEffect, ReactNode, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Toaster, toast } from '../components'; import { Toaster, toast } from '../components';
@ -6,7 +6,23 @@ const NotificationContext = createContext(null);
export const useNotification = () => useContext(NotificationContext); export const useNotification = () => useContext(NotificationContext);
const NotificationProvider = ({ children, service }) => { // Notification deduplication cache
interface NotificationCacheEntry {
timestamp: number;
id: string;
}
interface NotificationProviderProps {
children: ReactNode;
service?: any;
deduplicationInterval?: number;
}
const NotificationProvider = ({
children,
service,
deduplicationInterval = 10000, // Default to 10 seconds
}: NotificationProviderProps) => {
const DEFAULT_OPTIONS = { const DEFAULT_OPTIONS = {
title: '', title: '',
message: '', message: '',
@ -15,39 +31,138 @@ const NotificationProvider = ({ children, service }) => {
type: 'info', // info, success, error type: 'info', // info, success, error
}; };
// Cache for recent notifications to prevent duplicates
// Structure: { [title_message_type]: { timestamp, id } }
const recentNotificationsRef = useRef<Record<string, NotificationCacheEntry>>({});
// Use the configurable deduplication interval from props
const show = useCallback(options => { const show = useCallback(options => {
const { title, message, duration, position, type, promise } = { const {
title,
message,
duration,
position,
type,
promise,
allowDuplicates = false,
deduplicationInterval: optionsDeduplicationInterval,
action,
} = {
...DEFAULT_OPTIONS, ...DEFAULT_OPTIONS,
...options, ...options,
}; };
// Use the provider's deduplicationInterval by default, but allow it to be overridden per notification
const notificationDeduplicationInterval = optionsDeduplicationInterval || deduplicationInterval;
if (promise) { if (promise) {
return toast.promise(promise, { return toast.promise(promise, {
loading: title || 'Loading...', loading: title || 'Loading...',
success: (data: unknown) => ({ success: (data: unknown) => {
title: title || 'Success', const description = typeof message === 'function' ? message(data) : message;
description: typeof message === 'function' ? message(data) : message, return {
}), title: title || 'Success',
error: (err: unknown) => ({ description,
title: title || 'Error', };
description: typeof message === 'function' ? message(err) : message, },
}), error: (err: unknown) => {
const description = typeof message === 'function' ? message(err) : message;
return {
title: title || 'Error',
description,
};
},
}); });
} }
return toast[type](title, { // Create a cache key from notification properties
const messageStr = typeof message === 'function' ? 'function' : message;
const cacheKey = `${title}_${messageStr}_${type}`;
// Handle deduplication
if (!allowDuplicates && type === 'error') {
const now = Date.now();
const cachedNotification = recentNotificationsRef.current[cacheKey];
// First check if we've shown this notification recently
if (cachedNotification) {
const timeSinceLastShown = now - cachedNotification.timestamp;
// If it's been shown recently and within the deduplication interval,
// don't show it again
if (timeSinceLastShown < notificationDeduplicationInterval) {
console.debug(
`Prevented duplicate notification: "${title}" (${timeSinceLastShown}ms < ${notificationDeduplicationInterval}ms)`
);
// Return the existing notification ID
return cachedNotification.id;
}
// If it's been shown before but outside the deduplication interval,
// dismiss the old notification first and allow showing a new one
console.debug(
`Showing notification again after interval: "${title}" (${timeSinceLastShown}ms >= ${notificationDeduplicationInterval}ms)`
);
toast.dismiss(cachedNotification.id);
}
}
// Show the notification with action if provided
const toastOptions = {
duration, duration,
position, position,
description: message, description: message,
}); id: options.id, // Use provided ID if available
};
// Add action button if provided
if (action && action.label && typeof action.onClick === 'function') {
toastOptions.action = {
label: action.label,
onClick: action.onClick,
};
}
const id = toast[type](title, toastOptions);
// Cache this notification for deduplication if it's an error
if (type === 'error') {
const timestamp = Date.now();
recentNotificationsRef.current[cacheKey] = {
timestamp,
id,
};
console.debug(
`Stored notification in cache: "${title}" (id: ${id}, timestamp: ${timestamp})`
);
// We no longer auto-delete the cache entry after duration
// Instead, we keep it to track when the notification was last shown
// The entry will be checked against the deduplication interval
}
return id;
}, []); }, []);
const hide = useCallback(id => { const hide = useCallback(id => {
toast.dismiss(id); toast.dismiss(id);
// Remove from cache if present
const cacheEntries = Object.entries(recentNotificationsRef.current);
for (const [key, entry] of cacheEntries) {
if (entry.id === id) {
delete recentNotificationsRef.current[key];
break;
}
}
}, []); }, []);
const hideAll = useCallback(() => { const hideAll = useCallback(() => {
toast.dismiss(); toast.dismiss();
// Clear notification cache
recentNotificationsRef.current = {};
}, []); }, []);
/** /**
@ -61,8 +176,28 @@ const NotificationProvider = ({ children, service }) => {
} }
}, [service, hide, show]); }, [service, hide, show]);
// Debug function to get the current cache (for development use)
const getNotificationCache = useCallback(() => {
const cache = { ...recentNotificationsRef.current };
// Add human-readable timestamps and time since showing
const now = Date.now();
const enhancedCache = Object.entries(cache).reduce((result, [key, entry]) => {
const timeSince = now - entry.timestamp;
result[key] = {
...entry,
date: new Date(entry.timestamp).toISOString(),
timeSinceMs: timeSince,
timeSinceStr: `${Math.floor(timeSince / 1000)}s ago`,
};
return result;
}, {});
return enhancedCache;
}, []);
return ( return (
<NotificationContext.Provider value={{ show, hide, hideAll }}> <NotificationContext.Provider value={{ show, hide, hideAll, getNotificationCache }}>
<Toaster position="bottom-right" /> <Toaster position="bottom-right" />
{children} {children}
</NotificationContext.Provider> </NotificationContext.Provider>
@ -71,6 +206,8 @@ const NotificationProvider = ({ children, service }) => {
NotificationProvider.propTypes = { NotificationProvider.propTypes = {
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
service: PropTypes.object,
deduplicationInterval: PropTypes.number,
}; };
export const withNotification = Component => { export const withNotification = Component => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 297 KiB