From c900e73d7940636d0c1aad1572a0f6c9b0f762e8 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 16 Sep 2025 13:13:38 -0400 Subject: [PATCH 01/27] feat: add method to get user's groups --- fileglancer/handlers.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index 1bf1c7c8..9b5ab94c 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -2,6 +2,8 @@ import json import requests import re +import grp +import pwd from datetime import datetime, timezone from abc import ABC from mimetypes import guess_type @@ -56,6 +58,7 @@ def _get_mounted_filestore(fsp): class BaseHandler(APIHandler): _home_file_share_path_cache = {} + _groups_cache = {} def get_current_user(self): """ @@ -98,6 +101,36 @@ def get_home_file_share_path_name(self): return None + def get_user_groups(self): + """ + Get the groups for the current user. + + Returns: + list: List of group names the user belongs to. + """ + username = self.get_current_user() + + if username in self._groups_cache: + return self._groups_cache[username] + + try: + user_info = pwd.getpwnam(username) + user_groups = [] + all_groups = grp.getgrall() # Get all groups on the system + for group in all_groups: + if username in group.gr_mem: # Check if user is a member of this group + user_groups.append(group.gr_name) + primary_group = grp.getgrgid(user_info.pw_gid).gr_name + if primary_group not in user_groups: # Add primary group if not already included + user_groups.append(primary_group) + self._groups_cache[username] = user_groups + return user_groups + except Exception as e: + self.log.error(f"Error getting groups for user {username}: {str(e)}") + self._groups_cache[username] = [] + return [] + + class StreamingProxy(BaseHandler): """ API handler for proxying responses from the central server From 0b864f76dcfae7138d3145b3608f0805bdbf3797 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 3 Oct 2025 11:54:38 -0400 Subject: [PATCH 02/27] feat: add filtering by group to FileSharePathsHandler - adds helper function for filtering - checks preference manager for "filter_zones_by_group" setting. If not present, defaults to True --- fileglancer/handlers.py | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index 9b5ab94c..5aa37a30 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -56,6 +56,15 @@ def _get_mounted_filestore(fsp): return filestore +def _should_show_fsp(fsp, user_groups): + """ + Determine if a file share path should be shown to the user based on the user's groups. + """ + if fsp.group == "public" or fsp.group in user_groups: + return True + return False + + class BaseHandler(APIHandler): _home_file_share_path_cache = {} _groups_cache = {} @@ -163,17 +172,36 @@ class FileSharePathsHandler(BaseHandler): """ @web.authenticated def get(self): - self.log.info("GET /api/fileglancer/file-share-paths") + username = self.get_current_user() + self.log.info("GET /api/fileglancer/file-share-paths username={username}") file_share_paths = get_fsp_manager(self.settings).get_file_share_paths() + + # Check user preference for group filtering + preference_manager = get_preference_manager(self.settings) + + try: + filter_by_groups = preference_manager.get_preference(username, "filter_zones_by_group") + except KeyError: + filter_by_groups = True # Default to True if preference not set + except Exception as e: + self.log.error(f"Error getting user preference: {str(e)}") + filter_by_groups = False # Default to False on an error other than KeyError. Better to show too many zones than too few. + + if filter_by_groups: + user_groups = self.get_user_groups() + filtered_paths = [fsp for fsp in file_share_paths if _should_show_fsp(fsp, user_groups)] + # Convert Pydantic objects to dicts before JSON serialization + response_data = {"paths": [fsp.model_dump() for fsp in filtered_paths]} + else: + # Convert Pydantic objects to dicts before JSON serialization + file_share_paths_json = {"paths": [fsp.model_dump() for fsp in file_share_paths]} + self.set_header('Content-Type', 'application/json') self.set_status(200) - # Convert Pydantic objects to dicts before JSON serialization - file_share_paths_json = {"paths": [fsp.model_dump() for fsp in file_share_paths]} - self.write(json.dumps(file_share_paths_json)) + self.write(json.dumps(response_data)) self.finish() - class FileShareHandler(BaseHandler, ABC): """ Abstract base handler for endpoints that use the Filestore class. From 558b788725fb8bf7890ff21ac98f7af77f2e8c1a Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 3 Oct 2025 14:00:57 -0400 Subject: [PATCH 03/27] fix: return fsp is group is local - this is the case where no fsps are available from the central server (local setup) --- fileglancer/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index 245cffc6..4c654f28 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -60,7 +60,7 @@ def _should_show_fsp(fsp, user_groups): """ Determine if a file share path should be shown to the user based on the user's groups. """ - if fsp.group == "public" or fsp.group in user_groups: + if fsp.group == "public" or fsp.group == 'local' or fsp.group in user_groups: return True return False From f98a712cd72fab324954f8e054fce7f9f6e70d81 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 3 Oct 2025 14:05:52 -0400 Subject: [PATCH 04/27] fix: update old variable name to new one --- fileglancer/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index 4c654f28..4b81c430 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -194,7 +194,7 @@ def get(self): response_data = {"paths": [fsp.model_dump() for fsp in filtered_paths]} else: # Convert Pydantic objects to dicts before JSON serialization - file_share_paths_json = {"paths": [fsp.model_dump() for fsp in file_share_paths]} + response_data = {"paths": [fsp.model_dump() for fsp in file_share_paths]} self.set_header('Content-Type', 'application/json') self.set_status(200) From bfeeb2c2d1920c335c9255bfb37ec7fbcf4efeec Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 16 Sep 2025 13:13:38 -0400 Subject: [PATCH 05/27] feat: add method to get user's groups --- fileglancer/handlers.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index 3f21c99e..c287525e 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -2,6 +2,8 @@ import json import requests import re +import grp +import pwd from datetime import datetime, timezone from abc import ABC from mimetypes import guess_type @@ -56,6 +58,7 @@ def _get_mounted_filestore(fsp): class BaseHandler(APIHandler): _home_file_share_path_cache = {} + _groups_cache = {} def get_current_user(self): """ @@ -98,6 +101,36 @@ def get_home_file_share_path_name(self): return None + def get_user_groups(self): + """ + Get the groups for the current user. + + Returns: + list: List of group names the user belongs to. + """ + username = self.get_current_user() + + if username in self._groups_cache: + return self._groups_cache[username] + + try: + user_info = pwd.getpwnam(username) + user_groups = [] + all_groups = grp.getgrall() # Get all groups on the system + for group in all_groups: + if username in group.gr_mem: # Check if user is a member of this group + user_groups.append(group.gr_name) + primary_group = grp.getgrgid(user_info.pw_gid).gr_name + if primary_group not in user_groups: # Add primary group if not already included + user_groups.append(primary_group) + self._groups_cache[username] = user_groups + return user_groups + except Exception as e: + self.log.error(f"Error getting groups for user {username}: {str(e)}") + self._groups_cache[username] = [] + return [] + + class StreamingProxy(BaseHandler): """ API handler for proxying responses from the central server From 7e4984bc715c59da18e9bb422cb6d126e273a48e Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 3 Oct 2025 11:54:38 -0400 Subject: [PATCH 06/27] feat: add filtering by group to FileSharePathsHandler - adds helper function for filtering - checks preference manager for "filter_zones_by_group" setting. If not present, defaults to True --- fileglancer/handlers.py | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index c287525e..245cffc6 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -56,6 +56,15 @@ def _get_mounted_filestore(fsp): return filestore +def _should_show_fsp(fsp, user_groups): + """ + Determine if a file share path should be shown to the user based on the user's groups. + """ + if fsp.group == "public" or fsp.group in user_groups: + return True + return False + + class BaseHandler(APIHandler): _home_file_share_path_cache = {} _groups_cache = {} @@ -163,17 +172,36 @@ class FileSharePathsHandler(BaseHandler): """ @web.authenticated def get(self): - self.log.info("GET /api/fileglancer/file-share-paths") + username = self.get_current_user() + self.log.info("GET /api/fileglancer/file-share-paths username={username}") file_share_paths = get_fsp_manager(self.settings).get_file_share_paths() + + # Check user preference for group filtering + preference_manager = get_preference_manager(self.settings) + + try: + filter_by_groups = preference_manager.get_preference(username, "filter_zones_by_group") + except KeyError: + filter_by_groups = True # Default to True if preference not set + except Exception as e: + self.log.error(f"Error getting user preference: {str(e)}") + filter_by_groups = False # Default to False on an error other than KeyError. Better to show too many zones than too few. + + if filter_by_groups: + user_groups = self.get_user_groups() + filtered_paths = [fsp for fsp in file_share_paths if _should_show_fsp(fsp, user_groups)] + # Convert Pydantic objects to dicts before JSON serialization + response_data = {"paths": [fsp.model_dump() for fsp in filtered_paths]} + else: + # Convert Pydantic objects to dicts before JSON serialization + file_share_paths_json = {"paths": [fsp.model_dump() for fsp in file_share_paths]} + self.set_header('Content-Type', 'application/json') self.set_status(200) - # Convert Pydantic objects to dicts before JSON serialization - file_share_paths_json = {"paths": [fsp.model_dump() for fsp in file_share_paths]} - self.write(json.dumps(file_share_paths_json)) + self.write(json.dumps(response_data)) self.finish() - class FileShareHandler(BaseHandler, ABC): """ Abstract base handler for endpoints that use the Filestore class. From 849b647cc0a0dfea492587d9df1d7579bced90c1 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 3 Oct 2025 14:00:57 -0400 Subject: [PATCH 07/27] fix: return fsp is group is local - this is the case where no fsps are available from the central server (local setup) --- fileglancer/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index 245cffc6..4c654f28 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -60,7 +60,7 @@ def _should_show_fsp(fsp, user_groups): """ Determine if a file share path should be shown to the user based on the user's groups. """ - if fsp.group == "public" or fsp.group in user_groups: + if fsp.group == "public" or fsp.group == 'local' or fsp.group in user_groups: return True return False From 087266c28e91d1b8e3da537096974a2ba807e4c7 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 3 Oct 2025 14:05:52 -0400 Subject: [PATCH 08/27] fix: update old variable name to new one --- fileglancer/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index 4c654f28..4b81c430 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -194,7 +194,7 @@ def get(self): response_data = {"paths": [fsp.model_dump() for fsp in filtered_paths]} else: # Convert Pydantic objects to dicts before JSON serialization - file_share_paths_json = {"paths": [fsp.model_dump() for fsp in file_share_paths]} + response_data = {"paths": [fsp.model_dump() for fsp in file_share_paths]} self.set_header('Content-Type', 'application/json') self.set_status(200) From 48f7b7fa18a7a096ab566dcd9eebaf526c849709 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 3 Oct 2025 15:59:49 -0400 Subject: [PATCH 09/27] refactor: fetch all preferences with one useEffect and network request - as opposed to fetching each preference separately, with one request per preference --- src/contexts/PreferencesContext.tsx | 219 ++++++++++------------------ 1 file changed, 81 insertions(+), 138 deletions(-) diff --git a/src/contexts/PreferencesContext.tsx b/src/contexts/PreferencesContext.tsx index b50e829a..ecbc62e2 100644 --- a/src/contexts/PreferencesContext.tsx +++ b/src/contexts/PreferencesContext.tsx @@ -123,26 +123,23 @@ export const PreferencesProvider = ({ useZoneAndFspMapContext(); const { fileBrowserState } = useFileBrowserContext(); - const fetchPreferences = React.useCallback( - async (key: string) => { - try { - const data = await sendFetchRequest( - `/api/fileglancer/preference?key=${key}`, - 'GET', - cookies['_xsrf'] - ).then(response => response.json()); - return data?.value; - } catch (error) { - if (error instanceof HTTPError && error.responseCode === 404) { - return null; // Preference not found is not an error - } else { - log.error(`Error fetching preference '${key}':`, error); - } - return null; + const fetchPreferences = React.useCallback(async () => { + try { + const data = await sendFetchRequest( + `/api/fileglancer/preference`, + 'GET', + cookies['_xsrf'] + ).then(response => response.json()); + return data; + } catch (error) { + if (error instanceof HTTPError && error.responseCode === 404) { + return {}; // No preferences found, return empty object + } else { + log.error(`Error fetching preferences:`, error); } - }, - [cookies] - ); + return {}; + } + }, [cookies]); const accessMapItems = React.useCallback( (keys: string[]) => { @@ -505,132 +502,47 @@ export const PreferencesProvider = ({ [recentlyViewedFolders] ); - React.useEffect(() => { - (async function () { - if (isLayoutLoadedFromDB) { - return; // Avoid re-fetching if already loaded - } - const rawLayoutPref = await fetchPreferences('layout'); - if (rawLayoutPref) { - setLayout(rawLayoutPref); - } - setIsLayoutLoadedFromDB(true); - })(); - }, [fetchPreferences, isLayoutLoadedFromDB]); - - React.useEffect(() => { - (async function () { - const rawPathPreference = await fetchPreferences('path'); - if (rawPathPreference) { - setPathPreference(rawPathPreference); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawHideDotFiles = await fetchPreferences('hideDotFiles'); - if (rawHideDotFiles !== null) { - setHideDotFiles(rawHideDotFiles); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawAreDataLinksAutomatic = await fetchPreferences( - 'areDataLinksAutomatic' - ); - if (rawAreDataLinksAutomatic !== null) { - setAreDataLinksAutomatic(rawAreDataLinksAutomatic); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawDisableNeuroglancerStateGeneration = await fetchPreferences( - 'disableNeuroglancerStateGeneration' - ); - if (rawDisableNeuroglancerStateGeneration !== null) { - setDisableNeuroglancerStateGeneration( - rawDisableNeuroglancerStateGeneration - ); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawDisableHeuristicalLayerTypeDetection = await fetchPreferences( - 'disableHeuristicalLayerTypeDetection' - ); - if (rawDisableHeuristicalLayerTypeDetection !== null) { - setDisableHeuristicalLayerTypeDetection( - rawDisableHeuristicalLayerTypeDetection - ); - } - })(); - }, [fetchPreferences]); - - React.useEffect(() => { - (async function () { - const rawUseLegacyMultichannelApproach = await fetchPreferences( - 'useLegacyMultichannelApproach' - ); - if (rawUseLegacyMultichannelApproach !== null) { - setUseLegacyMultichannelApproach(rawUseLegacyMultichannelApproach); - } - })(); - }, [fetchPreferences]); - + // Fetch all preferences on mount React.useEffect(() => { if (!isZonesMapReady) { return; } + if (isLayoutLoadedFromDB) { + return; // Avoid re-fetching if already loaded + } + setLoadingRecentlyViewedFolders(true); (async function () { - const backendPrefs = await fetchPreferences('zone'); + const allPrefs = await fetchPreferences(); + + // Zone favorites + const zoneBackendPrefs = allPrefs.zone?.value; const zoneArray = - backendPrefs?.map((pref: ZonePreference) => { + zoneBackendPrefs?.map((pref: ZonePreference) => { const key = makeMapKey(pref.type, pref.name); return { [key]: pref }; }) || []; const zoneMap = Object.assign({}, ...zoneArray); - if (zoneMap) { + if (Object.keys(zoneMap).length > 0) { updateLocalZonePreferenceStates(zoneMap); } - })(); - }, [isZonesMapReady, fetchPreferences, updateLocalZonePreferenceStates]); - - React.useEffect(() => { - if (!isZonesMapReady) { - return; - } - (async function () { - const backendPrefs = await fetchPreferences('fileSharePath'); + // FileSharePath favorites + const fspBackendPrefs = allPrefs.fileSharePath?.value; const fspArray = - backendPrefs?.map((pref: FileSharePathPreference) => { + fspBackendPrefs?.map((pref: FileSharePathPreference) => { const key = makeMapKey(pref.type, pref.name); return { [key]: pref }; }) || []; const fspMap = Object.assign({}, ...fspArray); - if (fspMap) { + if (Object.keys(fspMap).length > 0) { updateLocalFspPreferenceStates(fspMap); } - })(); - }, [isZonesMapReady, fetchPreferences, updateLocalFspPreferenceStates]); - - React.useEffect(() => { - if (!isZonesMapReady) { - return; - } - (async function () { - const backendPrefs = await fetchPreferences('folder'); + // Folder favorites + const folderBackendPrefs = allPrefs.folder?.value; const folderArray = - backendPrefs?.map((pref: FolderPreference) => { + folderBackendPrefs?.map((pref: FolderPreference) => { const key = makeMapKey( pref.type, `${pref.fspName}_${pref.folderPath}` @@ -638,28 +550,59 @@ export const PreferencesProvider = ({ return { [key]: pref }; }) || []; const folderMap = Object.assign({}, ...folderArray); - if (folderMap) { + if (Object.keys(folderMap).length > 0) { updateLocalFolderPreferenceStates(folderMap); } - })(); - }, [isZonesMapReady, fetchPreferences, updateLocalFolderPreferenceStates]); - // Get initial recently viewed folders from backend - React.useEffect(() => { - setLoadingRecentlyViewedFolders(true); - if (!isZonesMapReady) { - return; - } - (async function () { - const backendPrefs = (await fetchPreferences( - 'recentlyViewedFolders' - )) as FolderPreference[]; - if (backendPrefs && backendPrefs.length > 0) { - setRecentlyViewedFolders(backendPrefs); + // Recently viewed folders + const recentlyViewedBackendPrefs = allPrefs.recentlyViewedFolders?.value; + if (recentlyViewedBackendPrefs && recentlyViewedBackendPrefs.length > 0) { + setRecentlyViewedFolders(recentlyViewedBackendPrefs); + } + + // Layout preference + if (allPrefs.layout?.value) { + setLayout(allPrefs.layout.value); + } + + // Path preference + if (allPrefs.path?.value) { + setPathPreference(allPrefs.path.value); + } + + // Boolean preferences + if (allPrefs.hideDotFiles?.value !== undefined) { + setHideDotFiles(allPrefs.hideDotFiles.value); + } + if (allPrefs.areDataLinksAutomatic?.value !== undefined) { + setAreDataLinksAutomatic(allPrefs.areDataLinksAutomatic.value); + } + if (allPrefs.disableNeuroglancerStateGeneration?.value !== undefined) { + setDisableNeuroglancerStateGeneration( + allPrefs.disableNeuroglancerStateGeneration.value + ); + } + if (allPrefs.disableHeuristicalLayerTypeDetection?.value !== undefined) { + setDisableHeuristicalLayerTypeDetection( + allPrefs.disableHeuristicalLayerTypeDetection.value + ); + } + if (allPrefs.useLegacyMultichannelApproach?.value !== undefined) { + setUseLegacyMultichannelApproach( + allPrefs.useLegacyMultichannelApproach.value + ); } setLoadingRecentlyViewedFolders(false); + setIsLayoutLoadedFromDB(true); })(); - }, [fetchPreferences, isZonesMapReady]); + }, [ + fetchPreferences, + isZonesMapReady, + isLayoutLoadedFromDB, + updateLocalZonePreferenceStates, + updateLocalFspPreferenceStates, + updateLocalFolderPreferenceStates + ]); // Store last viewed folder path and FSP name to avoid duplicate updates const lastFolderPathRef = React.useRef(null); From 62bb11846eee1ea74b4ff00d01ada193188f3979 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 10:42:04 -0400 Subject: [PATCH 10/27] fix: revert get handler for file-share-paths back to original - also delete unneeded should_show_fsp function --- fileglancer/handlers.py | 39 +++++---------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index 4b81c430..fdbb1d46 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -54,16 +54,7 @@ def _get_mounted_filestore(fsp): except FileNotFoundError: return None return filestore - - -def _should_show_fsp(fsp, user_groups): - """ - Determine if a file share path should be shown to the user based on the user's groups. - """ - if fsp.group == "public" or fsp.group == 'local' or fsp.group in user_groups: - return True - return False - + class BaseHandler(APIHandler): _home_file_share_path_cache = {} @@ -172,33 +163,13 @@ class FileSharePathsHandler(BaseHandler): """ @web.authenticated def get(self): - username = self.get_current_user() - self.log.info("GET /api/fileglancer/file-share-paths username={username}") + self.log.info("GET /api/fileglancer/file-share-paths") file_share_paths = get_fsp_manager(self.settings).get_file_share_paths() - - # Check user preference for group filtering - preference_manager = get_preference_manager(self.settings) - - try: - filter_by_groups = preference_manager.get_preference(username, "filter_zones_by_group") - except KeyError: - filter_by_groups = True # Default to True if preference not set - except Exception as e: - self.log.error(f"Error getting user preference: {str(e)}") - filter_by_groups = False # Default to False on an error other than KeyError. Better to show too many zones than too few. - - if filter_by_groups: - user_groups = self.get_user_groups() - filtered_paths = [fsp for fsp in file_share_paths if _should_show_fsp(fsp, user_groups)] - # Convert Pydantic objects to dicts before JSON serialization - response_data = {"paths": [fsp.model_dump() for fsp in filtered_paths]} - else: - # Convert Pydantic objects to dicts before JSON serialization - response_data = {"paths": [fsp.model_dump() for fsp in file_share_paths]} - self.set_header('Content-Type', 'application/json') self.set_status(200) - self.write(json.dumps(response_data)) + # Convert Pydantic objects to dicts before JSON serialization + file_share_paths_json = {"paths": [fsp.model_dump() for fsp in file_share_paths]} + self.write(json.dumps(file_share_paths_json)) self.finish() From e07b3926a5f36ccdcb65e7c128fe0f86c9016b4a Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 10:42:21 -0400 Subject: [PATCH 11/27] feat: return groups in the profile get handler --- fileglancer/handlers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index fdbb1d46..5aee9015 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -1060,11 +1060,13 @@ def get(self): username = self.get_current_user() home_fsp_name = self.get_home_file_share_path_name() home_directory_name = os.path.basename(self.get_home_directory_path()) - self.log.info(f"GET /api/fileglancer/profile username={username} home_fsp_name={home_fsp_name} home_directory_name={home_directory_name}") + groups = self.get_user_groups() + self.log.info(f"GET /api/fileglancer/profile username={username} home_fsp_name={home_fsp_name} home_directory_name={home_directory_name} groups={groups}") response = { "username": username, "homeFileSharePathName": home_fsp_name, "homeDirectoryName": home_directory_name, + "groups": groups, } try: self.set_status(200) From 17e0e6f4866011246896c1cfae3742775a047b67 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 11:14:32 -0400 Subject: [PATCH 12/27] feat: add isFilteredByGroups and toggle func to Preferences context --- src/contexts/PreferencesContext.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/contexts/PreferencesContext.tsx b/src/contexts/PreferencesContext.tsx index ecbc62e2..af26928a 100644 --- a/src/contexts/PreferencesContext.tsx +++ b/src/contexts/PreferencesContext.tsx @@ -56,6 +56,8 @@ type PreferencesContextType = { loadingRecentlyViewedFolders: boolean; isLayoutLoadedFromDB: boolean; handleContextMenuFavorite: () => Promise>; + isFilteredByGroups: boolean; + toggleFilterByGroups: () => Promise>; }; const PreferencesContext = React.createContext( @@ -93,6 +95,8 @@ export const PreferencesProvider = ({ ] = React.useState(false); const [useLegacyMultichannelApproach, setUseLegacyMultichannelApproach] = React.useState(false); + const [isFilteredByGroups, setIsFilteredByGroups] = + React.useState(true); const [zonePreferenceMap, setZonePreferenceMap] = React.useState< Record >({}); @@ -254,6 +258,12 @@ export const PreferencesProvider = ({ [savePreferencesToBackend] ); + const toggleFilterByGroups = React.useCallback(async (): Promise< + Result + > => { + return await togglePreference('isFilteredByGroups', setIsFilteredByGroups); + }, [togglePreference]); + const toggleHideDotFiles = React.useCallback(async (): Promise< Result > => { @@ -571,6 +581,9 @@ export const PreferencesProvider = ({ } // Boolean preferences + if (allPrefs.isFilteredByGroups?.value !== undefined) { + setIsFilteredByGroups(allPrefs.isFilteredByGroups.value); + } if (allPrefs.hideDotFiles?.value !== undefined) { setHideDotFiles(allPrefs.hideDotFiles.value); } @@ -695,7 +708,9 @@ export const PreferencesProvider = ({ handleUpdateLayout, loadingRecentlyViewedFolders, isLayoutLoadedFromDB, - handleContextMenuFavorite + handleContextMenuFavorite, + isFilteredByGroups, + toggleFilterByGroups }} > {children} From 58faef0e49bc1ff3242547c5ef72fad4cafb7d2c Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 11:14:56 -0400 Subject: [PATCH 13/27] feat: add toggle for isFilteredByGroups to UI --- src/components/Preferences.tsx | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/components/Preferences.tsx b/src/components/Preferences.tsx index af5c0215..ffcf0b64 100644 --- a/src/components/Preferences.tsx +++ b/src/components/Preferences.tsx @@ -11,11 +11,13 @@ export default function Preferences() { pathPreference, handlePathPreferenceSubmit, hideDotFiles, + isFilteredByGroups, toggleHideDotFiles, disableNeuroglancerStateGeneration, toggleDisableNeuroglancerStateGeneration, disableHeuristicalLayerTypeDetection, - toggleDisableHeuristicalLayerTypeDetection + toggleDisableHeuristicalLayerTypeDetection, + toggleFilterByGroups } = usePreferencesContext(); return ( @@ -124,6 +126,34 @@ export default function Preferences() { Options: +
+ { + const result = await toggleFilterByGroups(); + if (result.success) { + toast.success( + !isFilteredByGroups + ? 'Only Zones for groups you have membership in are now visible' + : 'All Zones are now visible' + ); + } else { + toast.error(result.error); + } + }} + type="checkbox" + /> + + Filter Zones by group membership + +
+
Date: Mon, 6 Oct 2025 11:15:26 -0400 Subject: [PATCH 14/27] refactor: add groups to profile data type --- src/contexts/ProfileContext.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/contexts/ProfileContext.tsx b/src/contexts/ProfileContext.tsx index 4368eb30..4ed0eede 100644 --- a/src/contexts/ProfileContext.tsx +++ b/src/contexts/ProfileContext.tsx @@ -8,6 +8,7 @@ type Profile = { username: string; homeFileSharePathName: string; homeDirectoryName: string; + groups: string[]; }; type ProfileContextType = { From bb85beb01b0df00becf0d715c73af42e68e850af Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 11:16:16 -0400 Subject: [PATCH 15/27] refactor: add filtering by groups to hook; rename to reflect updated role --- src/components/ui/Sidebar/Sidebar.tsx | 5 +- ...ter.ts => useFilteredZonesAndFavorites.ts} | 62 ++++++++++++++++--- 2 files changed, 57 insertions(+), 10 deletions(-) rename src/hooks/{useSearchFilter.ts => useFilteredZonesAndFavorites.ts} (68%) diff --git a/src/components/ui/Sidebar/Sidebar.tsx b/src/components/ui/Sidebar/Sidebar.tsx index c7248ea8..06ca3280 100644 --- a/src/components/ui/Sidebar/Sidebar.tsx +++ b/src/components/ui/Sidebar/Sidebar.tsx @@ -4,7 +4,7 @@ import { HiOutlineFunnel, HiXMark } from 'react-icons/hi2'; import FavoritesBrowser from './FavoritesBrowser'; import ZonesBrowser from './ZonesBrowser'; -import useSearchFilter from '@/hooks/useSearchFilter'; +import useFilteredZonesAndFavorites from '@/hooks/useFilteredZonesAndFavorites'; export default function Sidebar() { const { @@ -15,7 +15,8 @@ export default function Sidebar() { filteredZoneFavorites, filteredFileSharePathFavorites, filteredFolderFavorites - } = useSearchFilter(); + } = useFilteredZonesAndFavorites(); + return (
diff --git a/src/hooks/useSearchFilter.ts b/src/hooks/useFilteredZonesAndFavorites.ts similarity index 68% rename from src/hooks/useSearchFilter.ts rename to src/hooks/useFilteredZonesAndFavorites.ts index 9f8bfabd..c7c425ec 100644 --- a/src/hooks/useSearchFilter.ts +++ b/src/hooks/useFilteredZonesAndFavorites.ts @@ -10,11 +10,17 @@ import { FolderFavorite, usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useProfileContext } from '@/contexts/ProfileContext'; -export default function useSearchFilter() { +export default function useFilteredZonesAndFavorites() { const { zonesAndFileSharePathsMap } = useZoneAndFspMapContext(); - const { zoneFavorites, fileSharePathFavorites, folderFavorites } = - usePreferencesContext(); + const { + zoneFavorites, + fileSharePathFavorites, + folderFavorites, + isFilteredByGroups + } = usePreferencesContext(); + const { profile } = useProfileContext(); const [searchQuery, setSearchQuery] = React.useState(''); const [filteredZonesMap, setFilteredZonesMap] = @@ -30,6 +36,8 @@ export default function useSearchFilter() { const filterZonesMap = React.useCallback( (query: string) => { + const userGroups = profile?.groups || []; + const matches = Object.entries(zonesAndFileSharePathsMap) .map(([key, value]) => { if (key.startsWith('zone')) { @@ -37,10 +45,17 @@ export default function useSearchFilter() { const zoneNameMatches = zone.name.toLowerCase().includes(query); // Filter the file share paths inside the zone - const matchingFileSharePaths = zone.fileSharePaths.filter(fsp => + let matchingFileSharePaths = zone.fileSharePaths.filter(fsp => fsp.name.toLowerCase().includes(query) ); + // Apply group filtering if enabled + if (isFilteredByGroups && userGroups.length > 0) { + matchingFileSharePaths = matchingFileSharePaths.filter(fsp => + userGroups.includes(fsp.group) || fsp.group === 'public' + ); + } + // If Zone.name matches or any FileSharePath.name inside the zone matches, // return a modified Zone object with only the matching file share paths if (zoneNameMatches || matchingFileSharePaths.length > 0) { @@ -59,7 +74,7 @@ export default function useSearchFilter() { setFilteredZonesMap(Object.fromEntries(matches as [string, Zone][])); }, - [zonesAndFileSharePathsMap] + [zonesAndFileSharePathsMap, isFilteredByGroups, profile] ); const filterAllFavorites = React.useCallback( @@ -112,8 +127,37 @@ export default function useSearchFilter() { if (searchQuery !== '') { filterZonesMap(searchQuery); filterAllFavorites(searchQuery); - } else if (searchQuery === '') { - // When search query is empty, use all the original paths + } else if (searchQuery === '' && isFilteredByGroups && profile?.groups) { + // When search query is empty but group filtering is enabled, apply group filter + const userGroups = profile.groups; + const groupFilteredMap = Object.entries(zonesAndFileSharePathsMap) + .map(([key, value]) => { + if (key.startsWith('zone')) { + const zone = value as Zone; + const matchingFileSharePaths = zone.fileSharePaths.filter(fsp => + userGroups.includes(fsp.group) || fsp.group === 'public' + ); + if (matchingFileSharePaths.length > 0) { + return [ + key, + { + ...zone, + fileSharePaths: matchingFileSharePaths + } + ]; + } + } + return null; + }) + .filter(Boolean); + setFilteredZonesMap( + Object.fromEntries(groupFilteredMap as [string, Zone][]) + ); + setFilteredZoneFavorites([]); + setFilteredFileSharePathFavorites([]); + setFilteredFolderFavorites([]); + } else { + // When search query is empty and group filtering is disabled, use all the original paths setFilteredZonesMap({}); setFilteredZoneFavorites([]); setFilteredFileSharePathFavorites([]); @@ -126,7 +170,9 @@ export default function useSearchFilter() { fileSharePathFavorites, folderFavorites, filterAllFavorites, - filterZonesMap + filterZonesMap, + isFilteredByGroups, + profile ]); return { From e15c341a1d0d7742d58763b97e290972a550bcee Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 11:16:40 -0400 Subject: [PATCH 16/27] feat: add filtering message and link to prefs page to Zones browser --- src/components/ui/Sidebar/ZonesBrowser.tsx | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/components/ui/Sidebar/ZonesBrowser.tsx b/src/components/ui/Sidebar/ZonesBrowser.tsx index 0a6e87cd..dd85d363 100644 --- a/src/components/ui/Sidebar/ZonesBrowser.tsx +++ b/src/components/ui/Sidebar/ZonesBrowser.tsx @@ -4,9 +4,11 @@ import { HiSquares2X2 } from 'react-icons/hi2'; import { ZonesAndFileSharePathsMap } from '@/shared.types'; import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; import useOpenZones from '@/hooks/useOpenZones'; import Zone from './Zone'; import { SidebarItemSkeleton } from '@/components/ui/widgets/Loaders'; +import { Link } from 'react-router'; export default function ZonesBrowser({ searchQuery, @@ -17,6 +19,7 @@ export default function ZonesBrowser({ }) { const { zonesAndFileSharePathsMap, areZoneDataLoading } = useZoneAndFspMapContext(); + const { isFilteredByGroups } = usePreferencesContext(); const { openZones, toggleOpenZones } = useOpenZones(); const displayZones: ZonesAndFileSharePathsMap = @@ -78,6 +81,36 @@ export default function ZonesBrowser({ } }) )} + +
+ {isFilteredByGroups ? ( + <> + + Showing only Zones for groups you have membership in. + + + Modify your{' '} + + preferences + {' '} + to see all Zones. + + + ) : ( + <> + + Showing all Zones. + + + Modify your{' '} + + preferences + {' '} + to see only Zones for groups you have membership in. + + + )} +
)} From 6ccd1c6c1f602c677923e9245d9d66f9ca6f2fa0 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 11:19:16 -0400 Subject: [PATCH 17/27] chore: linter changes --- src/components/Browse.tsx | 4 ++-- src/components/ui/Sidebar/FavoritesBrowser.tsx | 8 ++++---- src/components/ui/Sidebar/Folder.tsx | 2 +- src/components/ui/Sidebar/Sidebar.tsx | 12 ++++++------ src/components/ui/Sidebar/ZonesBrowser.tsx | 2 +- src/hooks/useFilteredZonesAndFavorites.ts | 8 ++++---- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/components/Browse.tsx b/src/components/Browse.tsx index d1d76e17..0807a8c6 100644 --- a/src/components/Browse.tsx +++ b/src/components/Browse.tsx @@ -67,9 +67,7 @@ export default function Browse() { return (
{ log.debug('React paste event fired!', event); @@ -108,6 +106,8 @@ export default function Browse() { log.debug('Text input is focused, ignoring paste'); } }} + ref={containerRef} + tabIndex={0} tabIndex={0} > ); })} {/* File share path favorites */} {displayFileSharePaths.map((fsp, index) => { - return ; + return ; })} {/* Directory favorites */} {displayFolders.map(folderFavorite => { return ( ); })} diff --git a/src/components/ui/Sidebar/Folder.tsx b/src/components/ui/Sidebar/Folder.tsx index 3725836a..f88f7ae8 100644 --- a/src/components/ui/Sidebar/Folder.tsx +++ b/src/components/ui/Sidebar/Folder.tsx @@ -92,6 +92,7 @@ export default function Folder({ <> ) => handleSearchChange(e) } + placeholder="Type to filter zones" + type="search" + value={searchQuery} > @@ -35,10 +35,10 @@ export default function Sidebar() { {searchQuery ? ( diff --git a/src/components/ui/Sidebar/ZonesBrowser.tsx b/src/components/ui/Sidebar/ZonesBrowser.tsx index dd85d363..258c51a1 100644 --- a/src/components/ui/Sidebar/ZonesBrowser.tsx +++ b/src/components/ui/Sidebar/ZonesBrowser.tsx @@ -73,9 +73,9 @@ export default function ZonesBrowser({ return ( ); } diff --git a/src/hooks/useFilteredZonesAndFavorites.ts b/src/hooks/useFilteredZonesAndFavorites.ts index c7c425ec..728293a6 100644 --- a/src/hooks/useFilteredZonesAndFavorites.ts +++ b/src/hooks/useFilteredZonesAndFavorites.ts @@ -51,8 +51,8 @@ export default function useFilteredZonesAndFavorites() { // Apply group filtering if enabled if (isFilteredByGroups && userGroups.length > 0) { - matchingFileSharePaths = matchingFileSharePaths.filter(fsp => - userGroups.includes(fsp.group) || fsp.group === 'public' + matchingFileSharePaths = matchingFileSharePaths.filter( + fsp => userGroups.includes(fsp.group) || fsp.group === 'public' ); } @@ -134,8 +134,8 @@ export default function useFilteredZonesAndFavorites() { .map(([key, value]) => { if (key.startsWith('zone')) { const zone = value as Zone; - const matchingFileSharePaths = zone.fileSharePaths.filter(fsp => - userGroups.includes(fsp.group) || fsp.group === 'public' + const matchingFileSharePaths = zone.fileSharePaths.filter( + fsp => userGroups.includes(fsp.group) || fsp.group === 'public' ); if (matchingFileSharePaths.length > 0) { return [ From 7504eedbacef7e7c531ce6631adc70d3f8522ecf Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 11:21:40 -0400 Subject: [PATCH 18/27] chore: linter --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1a713564..43975483 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ See the [Fileglancer User Guide](https://janeliascicomp.github.io/fileglancer-us ## Software Architecture -Fileglancer is built on top of JuptyerHub, which provides the infrastructure for allowing users to login and interact directly with their files on mounted network file systems. JupyterHub runs a "single user server" for each user who logs in, in a process owned by that user. The Fileglancer plugin for JupyterHub replaces the UI with a new SPA webapp that connects back to a custom backend running inside the single user server. We also added a "central server" to serve shared data and to manage connections to a shared database for saving preferences, data links, and other persistent information. +Fileglancer is built on top of JuptyerHub, which provides the infrastructure for allowing users to login and interact directly with their files on mounted network file systems. JupyterHub runs a "single user server" for each user who logs in, in a process owned by that user. The Fileglancer plugin for JupyterHub replaces the UI with a new SPA webapp that connects back to a custom backend running inside the single user server. We also added a "central server" to serve shared data and to manage connections to a shared database for saving preferences, data links, and other persistent information. Fileglancer architecture diagram From 6b641c438e7a705148531da915b0047431f1bd8a Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 11:44:48 -0400 Subject: [PATCH 19/27] fix: default to no filtering for local zones; make zones selector more specific --- src/contexts/PreferencesContext.tsx | 20 +++++++++++++++----- ui-tests/tests/localApp/localzone.spec.ts | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/contexts/PreferencesContext.tsx b/src/contexts/PreferencesContext.tsx index af26928a..8b71c059 100644 --- a/src/contexts/PreferencesContext.tsx +++ b/src/contexts/PreferencesContext.tsx @@ -3,8 +3,9 @@ import { default as log } from '@/logger'; import type { FileSharePath, Zone } from '@/shared.types'; import { useCookiesContext } from '@/contexts/CookiesContext'; -import { useZoneAndFspMapContext } from './ZonesAndFspMapContext'; -import { useFileBrowserContext } from './FileBrowserContext'; +import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; +import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +import { useCentralServerHealthContext } from '@/contexts/CentralServerHealthContext'; import { sendFetchRequest, makeMapKey, HTTPError } from '@/utils'; import { createSuccess, handleError, toHttpError } from '@/utils/errorHandling'; import type { Result } from '@/shared.types'; @@ -95,8 +96,6 @@ export const PreferencesProvider = ({ ] = React.useState(false); const [useLegacyMultichannelApproach, setUseLegacyMultichannelApproach] = React.useState(false); - const [isFilteredByGroups, setIsFilteredByGroups] = - React.useState(true); const [zonePreferenceMap, setZonePreferenceMap] = React.useState< Record >({}); @@ -122,6 +121,12 @@ export const PreferencesProvider = ({ const [layout, setLayout] = React.useState(''); const [isLayoutLoadedFromDB, setIsLayoutLoadedFromDB] = React.useState(false); + const { status } = useCentralServerHealthContext(); + // If Central Server status is 'ignore', default to false to skip filtering by groups + const [isFilteredByGroups, setIsFilteredByGroups] = React.useState( + status === 'ignore' ? false : true + ); + const { cookies } = useCookiesContext(); const { isZonesMapReady, zonesAndFileSharePathsMap } = useZoneAndFspMapContext(); @@ -261,8 +266,13 @@ export const PreferencesProvider = ({ const toggleFilterByGroups = React.useCallback(async (): Promise< Result > => { + if (status === 'ignore') { + return handleError( + new Error('Cannot filter by groups; central server configuration issue') + ); + } return await togglePreference('isFilteredByGroups', setIsFilteredByGroups); - }, [togglePreference]); + }, [togglePreference, status]); const toggleHideDotFiles = React.useCallback(async (): Promise< Result diff --git a/ui-tests/tests/localApp/localzone.spec.ts b/ui-tests/tests/localApp/localzone.spec.ts index ec4759ea..807c478f 100644 --- a/ui-tests/tests/localApp/localzone.spec.ts +++ b/ui-tests/tests/localApp/localzone.spec.ts @@ -6,7 +6,7 @@ test.beforeEach('Open fileglancer', async ({ page }) => { }); test('Home becomes visible when Local is expanded', async ({ page }) => { - const zonesLocator = page.getByText('Zones'); + const zonesLocator = page.getByText('Zones', { exact: true }); const homeLocator = page.getByRole('link', { name: 'home', exact: true }); const localZoneLocator = page.getByText('Local'); From a2ce63e309407746350dad118951da9668e41da2 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 11:45:09 -0400 Subject: [PATCH 20/27] fix: try to fix docs link error in GH action --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 43975483..0b9179a4 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The current code base is geared towards a Janelia deployment, but we are working ## Documentation - [User guide](https://janeliascicomp.github.io/fileglancer-user-docs/) -- [Developer guide](docs/Development.md) +- [Developer guide](./docs/Development.md) ## Related repositories From 283fdb61838f7285040e89f4b33b66455f2360e5 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 12:00:17 -0400 Subject: [PATCH 21/27] fix: add add'tl context provider to test setup to fix failures --- src/__tests__/test-utils.tsx | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/__tests__/test-utils.tsx b/src/__tests__/test-utils.tsx index b610dcfb..8d99a216 100644 --- a/src/__tests__/test-utils.tsx +++ b/src/__tests__/test-utils.tsx @@ -14,6 +14,7 @@ import { OpenFavoritesProvider } from '@/contexts/OpenFavoritesContext'; import { TicketProvider } from '@/contexts/TicketsContext'; import { ProfileContextProvider } from '@/contexts/ProfileContext'; import { ExternalBucketProvider } from '@/contexts/ExternalBucketContext'; +import { CentralServerHealthProvider } from '@/contexts/CentralServerHealthContext'; import ErrorFallback from '@/components/ErrorFallback'; interface CustomRenderOptions extends Omit { @@ -39,21 +40,23 @@ const FileBrowserTestingWrapper = ({ const Browse = ({ children }: { children: React.ReactNode }) => { return ( - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + ); }; From a51abfb05e2b9a3177011e03848e79ecae9e3964 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 12:40:28 -0400 Subject: [PATCH 22/27] fix: links for gh build action --- README.md | 4 ++-- docs/Development.md | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0b9179a4..783f32d2 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ The current code base is geared towards a Janelia deployment, but we are working ## Documentation - [User guide](https://janeliascicomp.github.io/fileglancer-user-docs/) -- [Developer guide](./docs/Development.md) +- [Developer guide](docs/Development.md) ## Related repositories - [fileglancer-central](https://github.com/JaneliaSciComp/fileglancer-central) - Central server managing access to a shared database and other resources - [fileglancer-hub](https://github.com/JaneliaSciComp/fileglancer-hub) - Deployment of Fileglancer into JupyterHub -- [fileglancer-user-docs](https://github.com/JaneliaSciComp/fileglancer-user-docs) - User guide +- [fileglancer-user-docs](https://github.com/JaneliaSciComp/fileglancer-docs) - User guide diff --git a/docs/Development.md b/docs/Development.md index deae706b..4d325e05 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -31,7 +31,7 @@ Saved changes in your directory should now be automatically built locally and av If everything has worked so far, you should see the Fileglancer widget on the Launcher pane: -![Screenshot of the JupyterLab Launcher panel. In the bottom section, titled "Other", the square tile with the title "Fileglancer" is circled](./assets/img/launcher.png) +![Screenshot of the JupyterLab Launcher panel. In the bottom section, titled "Other", the square tile with the title "Fileglancer" is circled](../assets/img/launcher.png) ### Troubleshooting the extension @@ -96,9 +96,16 @@ pixi run test-frontend This extension uses [Playwright](https://playwright.dev/docs/intro) for the integration tests (aka user level tests). More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab. -More information are provided within the [ui-tests](./ui-tests/README.md) README. -To execute the UI integration test, run: +To execute the UI integration tests: + +Install test dependencies (needed only once): + +```bash +pixi run npm --prefix ui-tests npx playwright install +``` + +Then run the tests with: ```bash pixi run ui-test From 768faee6017c8583a3e5637604f2989ae55922b8 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 12:40:49 -0400 Subject: [PATCH 23/27] fix: toHaveTitle not working on page fixture --- ui-tests/tests/localApp/fileglancer.spec.ts | 9 +++------ ui-tests/tests/testutils.ts | 1 + 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/ui-tests/tests/localApp/fileglancer.spec.ts b/ui-tests/tests/localApp/fileglancer.spec.ts index c770535c..f4ee8d36 100644 --- a/ui-tests/tests/localApp/fileglancer.spec.ts +++ b/ui-tests/tests/localApp/fileglancer.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@jupyterlab/galata'; import { openFileGlancer } from '../testutils.ts'; +import type { IJupyterLabPageFixture } from '@jupyterlab/galata'; /** * Don't load JupyterLab webpage before running the tests. @@ -16,11 +17,9 @@ test('should emit an activation console message', async ({ page }) => { await page.goto(); - await expect( + expect( logs.filter(s => s === 'JupyterLab extension fileglancer is activated!') ).toHaveLength(1); - // we are still on the JupyterLab page - await expect(page).toHaveTitle('JupyterLab'); }); test('when fg icon clicked should open fileglancer extension', async ({ @@ -33,7 +32,5 @@ test('when fg icon clicked should open fileglancer extension', async ({ }); await openFileGlancer(page); - - // we are still on the Fileglancer page - await expect(page).toHaveTitle('Fileglancer'); + await expect(page.getByText('Browse')).toBeVisible(); }); diff --git a/ui-tests/tests/testutils.ts b/ui-tests/tests/testutils.ts index c33b7dae..30a1a418 100644 --- a/ui-tests/tests/testutils.ts +++ b/ui-tests/tests/testutils.ts @@ -1,4 +1,5 @@ import { Page } from '@playwright/test'; +import type { IJupyterLabPageFixture } from '@jupyterlab/galata'; const sleepInSecs = (secs: number) => new Promise(resolve => setTimeout(resolve, secs * 1000)); From 23cf217d89fd7fa78c5f537280162109fe9bd301 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 12:40:56 -0400 Subject: [PATCH 24/27] docs: update ui-test readme --- ui-tests/README.md | 150 +++------------------------------------------ 1 file changed, 7 insertions(+), 143 deletions(-) diff --git a/ui-tests/README.md b/ui-tests/README.md index 91825145..4f8fa078 100644 --- a/ui-tests/README.md +++ b/ui-tests/README.md @@ -10,158 +10,22 @@ The Playwright configuration is defined in [playwright.config.js](./playwright.c The JupyterLab server configuration to use for the integration test is defined in [jupyter_server_test_config.py](./jupyter_server_test_config.py). -The default configuration will produce video for failing tests and an HTML report. - -> There is a UI mode that you may like; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0). - ## Run the tests > All commands are assumed to be executed from the root directory To run the tests, you need to: -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Execute the [Playwright](https://playwright.dev/docs/intro) tests: - -```sh -cd ./ui-tests -jlpm playwright test -``` - -Test results will be shown in the terminal. In case of any test failures, the test report -will be opened in your browser at the end of the tests execution; see -[Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter) -for configuring that behavior. - -## Update the tests snapshots - -> All commands are assumed to be executed from the root directory - -If you are comparing snapshots to validate your tests, you may need to update -the reference snapshots stored in the repository. To do that, you need to: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Execute the [Playwright](https://playwright.dev/docs/intro) command: - -```sh -cd ./ui-tests -jlpm playwright test -u -``` - -> Some discrepancy may occurs between the snapshots generated on your computer and -> the one generated on the CI. To ease updating the snapshots on a PR, you can -> type `please update playwright snapshots` to trigger the update by a bot on the CI. -> Once the bot has computed new snapshots, it will commit them to the PR branch. - -## Create tests +Install test dependencies (needed only once): -> All commands are assumed to be executed from the root directory - -To create tests, the easiest way is to use the code generator tool of playwright: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Start the server: - -```sh -cd ./ui-tests -jlpm start -``` - -4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**: - -```sh -cd ./ui-tests -jlpm playwright codegen localhost:8888 +```bash +pixi run npx --prefix ui-tests playwright install ``` -## Debug tests +To execute the UI integration test, run: -> All commands are assumed to be executed from the root directory - -To debug tests, a good way is to use the inspector tool of playwright: - -1. Compile the extension: - -```sh -jlpm install -jlpm build:prod -``` - -> Check the extension is installed in JupyterLab. - -2. Install test dependencies (needed only once): - -```sh -cd ./ui-tests -jlpm install -jlpm playwright install -cd .. -``` - -3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug): - -```sh -cd ./ui-tests -jlpm playwright test --debug +```bash +pixi run ui-test ``` -## Upgrade Playwright and the browsers - -To update the web browser versions, you must update the package `@playwright/test`: - -```sh -cd ./ui-tests -jlpm up "@playwright/test" -jlpm playwright install -``` +For more information, please refer to the [Development](../docs/Development.md#integration-tests) documentation. From 1b8874c7ff12137e41084f20e8a960df1d32499b Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 6 Oct 2025 12:45:26 -0400 Subject: [PATCH 25/27] fix: update fileglancer-user-docs links to fileglancer-docs --- README.md | 6 +++--- src/components/Help.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 783f32d2..5e5cedf7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Core features: - Integration with our help desk (JIRA) for file conversion requests - Integration with our [x2s3](https://github.com/JaneliaSciComp/x2s3) proxy service, to easily share data on the internet -See the [Fileglancer User Guide](https://janeliascicomp.github.io/fileglancer-user-docs/) for more information. +See the [Fileglancer User Guide](https://janeliascicomp.github.io/fileglancer-docs/) for more information. Fileglancer screenshot @@ -26,11 +26,11 @@ The current code base is geared towards a Janelia deployment, but we are working ## Documentation -- [User guide](https://janeliascicomp.github.io/fileglancer-user-docs/) +- [User guide](https://janeliascicomp.github.io/fileglancer-docs/) - [Developer guide](docs/Development.md) ## Related repositories - [fileglancer-central](https://github.com/JaneliaSciComp/fileglancer-central) - Central server managing access to a shared database and other resources - [fileglancer-hub](https://github.com/JaneliaSciComp/fileglancer-hub) - Deployment of Fileglancer into JupyterHub -- [fileglancer-user-docs](https://github.com/JaneliaSciComp/fileglancer-docs) - User guide +- [fileglancer-docs](https://github.com/JaneliaSciComp/fileglancer-docs) - User guide diff --git a/src/components/Help.tsx b/src/components/Help.tsx index 3018b972..62964432 100644 --- a/src/components/Help.tsx +++ b/src/components/Help.tsx @@ -26,7 +26,7 @@ export default function Help() { title: 'User Manual', description: 'Comprehensive guide to Fileglancer features and functionality', - url: `https://janeliascicomp.github.io/fileglancer-user-docs/` + url: `https://janeliascicomp.github.io/fileglancer-docs/` }, { icon: TbBrandGithub, From 96591e2cfe814621bf8bc65c601465428b9a8370 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 7 Oct 2025 14:32:54 -0400 Subject: [PATCH 26/27] fix: revert file share paths handler back to no group filtering --- fileglancer/handlers.py | 38 +++++--------------------------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/fileglancer/handlers.py b/fileglancer/handlers.py index b79da2cb..2ee325c1 100644 --- a/fileglancer/handlers.py +++ b/fileglancer/handlers.py @@ -56,15 +56,6 @@ def _get_mounted_filestore(fsp): return filestore -def _should_show_fsp(fsp, user_groups): - """ - Determine if a file share path should be shown to the user based on the user's groups. - """ - if fsp.group == "public" or fsp.group == 'local' or fsp.group in user_groups: - return True - return False - - class BaseHandler(APIHandler): _home_file_share_path_cache = {} _groups_cache = {} @@ -172,33 +163,14 @@ class FileSharePathsHandler(BaseHandler): """ @web.authenticated def get(self): - username = self.get_current_user() - self.log.info("GET /api/fileglancer/file-share-paths username={username}") + self.log.info("GET /api/fileglancer/file-share-paths") file_share_paths = get_fsp_manager(self.settings).get_file_share_paths() - - # Check user preference for group filtering - preference_manager = get_preference_manager(self.settings) - - try: - filter_by_groups = preference_manager.get_preference(username, "filter_zones_by_group") - except KeyError: - filter_by_groups = True # Default to True if preference not set - except Exception as e: - self.log.error(f"Error getting user preference: {str(e)}") - filter_by_groups = False # Default to False on an error other than KeyError. Better to show too many zones than too few. - - if filter_by_groups: - user_groups = self.get_user_groups() - filtered_paths = [fsp for fsp in file_share_paths if _should_show_fsp(fsp, user_groups)] - # Convert Pydantic objects to dicts before JSON serialization - response_data = {"paths": [fsp.model_dump() for fsp in filtered_paths]} - else: - # Convert Pydantic objects to dicts before JSON serialization - response_data = {"paths": [fsp.model_dump() for fsp in file_share_paths]} - + self.set_header('Content-Type', 'application/json') self.set_status(200) - self.write(json.dumps(response_data)) + # Convert Pydantic objects to dicts before JSON serialization + file_share_paths_json = {"paths": [fsp.model_dump() for fsp in file_share_paths]} + self.write(json.dumps(file_share_paths_json)) self.finish() From 3f1b75a5a755ef29dc4895bb48cef5d61d35f717 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Wed, 8 Oct 2025 12:28:37 -0400 Subject: [PATCH 27/27] update wording --- src/components/Preferences.tsx | 2 +- src/components/ui/Sidebar/FavoritesBrowser.tsx | 4 ++-- src/components/ui/Sidebar/ZonesBrowser.tsx | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/Preferences.tsx b/src/components/Preferences.tsx index ffcf0b64..d8101444 100644 --- a/src/components/Preferences.tsx +++ b/src/components/Preferences.tsx @@ -150,7 +150,7 @@ export default function Preferences() { className="text-foreground" htmlFor="is_filtered_by_groups" > - Filter Zones by group membership + Display Zones for your groups only
diff --git a/src/components/ui/Sidebar/FavoritesBrowser.tsx b/src/components/ui/Sidebar/FavoritesBrowser.tsx index 5456709c..bb6445bb 100644 --- a/src/components/ui/Sidebar/FavoritesBrowser.tsx +++ b/src/components/ui/Sidebar/FavoritesBrowser.tsx @@ -75,10 +75,10 @@ export default function FavoritesBrowser({ displayFolders.length === 0 ? (
- No favorites match your filter. + No favorites match your filter '{searchQuery}' - Try broadening your search to see more results. + Try broadening your search to see more results
) : ( diff --git a/src/components/ui/Sidebar/ZonesBrowser.tsx b/src/components/ui/Sidebar/ZonesBrowser.tsx index 258c51a1..d595de57 100644 --- a/src/components/ui/Sidebar/ZonesBrowser.tsx +++ b/src/components/ui/Sidebar/ZonesBrowser.tsx @@ -61,10 +61,10 @@ export default function ZonesBrowser({ Object.keys(displayZones).length === 0 ? (
- No zones match your filter. + No zones match your filter '{searchQuery}' - Try broadening your search to see more results. + Try broadening your search to see more results
) : ( @@ -86,27 +86,27 @@ export default function ZonesBrowser({ {isFilteredByGroups ? ( <> - Showing only Zones for groups you have membership in. + Viewing Zones for your groups only Modify your{' '} preferences {' '} - to see all Zones. + to see all Zones ) : ( <> - Showing all Zones. + Viewing all Zones Modify your{' '} preferences {' '} - to see only Zones for groups you have membership in. + to see Zones for your groups only )}