11"""Main Panel application for the AIND SIGUI Portal."""
22
33import os
4+ import threading
45import param
56import panel as pn
67import pandas as pd
910
1011from aind_ephys_portal .docdb .database import get_raw_asset_by_name , get_all_ecephys_derived
1112from aind_ephys_portal .panel .utils import format_link , OUTER_STYLE , EPHYSGUI_LINK_PREFIX
12- from aind_ephys_portal .panel .logging import setup_logging
13+ from aind_ephys_portal .panel .logging import setup_logging , get_container_total_memory , get_container_used_memory
1314
1415s3_client = boto3 .client ("s3" )
1516
@@ -30,8 +31,9 @@ def __init__(self):
3031 setup_logging () # Ensure logging is set up for this panel
3132 # for test deployment, use v1 by default
3233 default_db_version = "v2" if os .environ .get ("TEST_ENV" , "0" ) == "0" else "v1"
33- # Initialize search options with default database version
34- self .search_options = SearchOptions (database_version = default_db_version )
34+ # Initialize search options without blocking on database load
35+ self .search_options = SearchOptions (database_version = default_db_version , defer_load = True )
36+ self ._db_loading = False
3537 # Get the search input widget
3638 self .search_bar = pn .widgets .TextInput (
3739 name = "Search" ,
@@ -46,18 +48,23 @@ def __init__(self):
4648 """
4749 self .results_panel = pn .widgets .Tabulator (
4850 pd .DataFrame (columns = ["name" , "subject_id" , "date" , "id" ]), # Empty DataFrame initially
49- height = 400 ,
51+ min_height = 400 ,
5052 selectable = True ,
5153 disabled = True ,
5254 show_index = False ,
5355 stylesheets = [stylesheet ],
5456 styles = {"background-color" : "#f5f5f5" , "padding" : "20px" , "border-radius" : "5px" },
5557 )
58+ self ._results_loading_pane = pn .pane .Markdown (
59+ "*Loading Database...*" ,
60+ styles = {"background-color" : "#f5f5f5" , "padding" : "20px" , "border-radius" : "5px" , "min-height" : "200px" },
61+ )
62+ self .results_container = pn .Column (self ._results_loading_pane )
5663
5764 # Create a streams panel to display postprocessed streams for the selected entry
5865 self .streams_panel = pn .widgets .Tabulator (
5966 pd .DataFrame (columns = ["Stream name" , "Ephys GUI View" ]), # Empty DataFrame initially
60- height = 200 ,
67+ min_height = 200 ,
6168 sizing_mode = "stretch_width" ,
6269 show_index = False ,
6370 disabled = True ,
@@ -66,6 +73,11 @@ def __init__(self):
6673 stylesheets = [stylesheet ],
6774 styles = {"background-color" : "#f5f5f5" , "padding" : "20px" , "border-radius" : "5px" },
6875 )
76+ self ._streams_loading_pane = pn .pane .Markdown (
77+ "*Loading postprocessed streams...*" ,
78+ styles = {"background-color" : "#f5f5f5" , "padding" : "20px" , "border-radius" : "5px" , "min-height" : "100px" },
79+ )
80+ self .streams_container = pn .Column (self .streams_panel )
6981
7082 # Update the results panel when the search input changes
7183 self .search_bar .param .watch (self .update_results , "value" )
@@ -79,32 +91,76 @@ def __init__(self):
7991 self .database_version_dropdown .param .watch (self .update_db_version , "value" )
8092 self .refresh_button = pn .widgets .Button (name = "Refresh Datasets" , button_type = "primary" , height = 30 , width = 150 )
8193 self .refresh_button .on_click (self .update_results )
82- # Initialize with current results
83- self .update_results (None )
94+ # Load database in a background thread so the UI renders immediately
95+ self ._load_database ()
96+
97+ def _run_in_background (self , target , on_complete ):
98+ """Run *target()* in a daemon thread; schedule *on_complete(result)* on the UI thread."""
99+ def _worker ():
100+ result = target ()
101+ pn .state .execute (lambda : on_complete (result ))
102+
103+ t = threading .Thread (target = _worker , daemon = True )
104+ t .start ()
105+
106+ def _set_db_loading (self , loading ):
107+ """Toggle the loading state and enable/disable controls accordingly."""
108+ self ._db_loading = loading
109+ self .refresh_button .disabled = loading
110+ self .database_version_dropdown .disabled = loading
111+
112+ def _load_database (self ):
113+ """Kick off a background thread to load/reload the database."""
114+ self ._set_db_loading (True )
115+ self .results_container [:] = [self ._results_loading_pane ]
116+ self .streams_container [:] = [self .streams_panel ]
117+ self .streams_panel .value = pd .DataFrame (columns = ["Stream name" , "Ephys GUI View" ])
118+
119+ def _do_load ():
120+ used = get_container_used_memory ()
121+ total = get_container_total_memory ()
122+ print (f"[Portal] RAM before DB load: { used / (1024 ** 3 ):.2f} GB / { total / (1024 ** 3 ):.2f} GB ({ used / total * 100 :.1f} %)" )
123+ self .search_options .update_options ()
124+ used = get_container_used_memory ()
125+ print (f"[Portal] RAM after DB load: { used / (1024 ** 3 ):.2f} GB / { total / (1024 ** 3 ):.2f} GB ({ used / total * 100 :.1f} %)" )
126+ return self .search_options .df
127+
128+ def _on_loaded (df ):
129+ self ._set_db_loading (False )
130+ self .results_panel .value = df
131+ self .results_container [:] = [self .results_panel ]
132+ print ("Database loaded." )
133+
134+ self ._run_in_background (_do_load , _on_loaded )
84135
85136 def update_db_version (self , event ):
86137 """Update the database version used for searching."""
87138 if event .new != event .old :
88139 new_version = event .new
89140 print (f"Switching to database version: { new_version } " )
90141 self .search_options .database_version = new_version
91- self .search_options .update_options ()
92- self .update_results (None )
142+ self ._load_database ()
93143
94144 def update_results (self , event ):
95145 """Update the results panel with the current search results."""
96146 print ("Updating search results..." )
97147 if event is None :
98148 df = self .search_options .df
99149 elif event .name == "clicks" :
100- self .search_options .update_options ()
101- df = self .search_options .df
150+ # Refresh button: reload from database in a background thread
151+ self ._load_database ()
152+ return
102153 else :
103154 # Filter the DataFrame based on the search input
155+ if self ._db_loading :
156+ # Database is still loading; nothing to filter yet
157+ return
104158 df = self .search_options .df_filtered (event .new )
105159 self .results_panel .value = df
160+ self .results_container [:] = [self .results_panel ]
106161 # Clear the streams panel when results are updated
107162 self .streams_panel .value = pd .DataFrame (columns = ["Stream name" , "Ephys GUI View" ])
163+ self .streams_container [:] = [self .streams_panel ]
108164
109165 def update_streams (self , event ):
110166 """Update the streams panel with the postprocessed streams for the selected entry."""
@@ -122,48 +178,57 @@ def update_streams(self, event):
122178 asset_name = record .get ("name" , "" )
123179 location = record .get ("location" , "" )
124180
125- loading_text = f"Loading postprocessed streams..."
126- streams_df = pd .DataFrame ({"Stream name" : [loading_text ], "Ephys GUI View" : ["" ]})
127- self .streams_panel .value = streams_df
128-
129- # Get the postprocessed streams for this location
130- stream_names = self .search_options .get_postprocessed_streams (location )
131- print (f"Found { len (stream_names )} postprocessed streams from { location } " )
132- analyzer_base_location = record ["location" ]
133- raw_asset = get_raw_asset_by_name (asset_name , version = db_version )[0 ]
134- links_url = []
135- for stream_name in stream_names :
136- raw_stream_name = stream_name [: stream_name .find ("_recording" )]
137- raw_asset_prefix = self .get_raw_asset_location (raw_asset ["location" ])
138- print (f"Raw asset prefix: { raw_asset_prefix } " )
139- if raw_asset_prefix is None :
140- recording_path = ""
141- else :
142- recording_path = f"{ raw_asset_prefix } /{ raw_stream_name } .zarr"
143- analyzer_path = f"{ analyzer_base_location } /postprocessed/{ stream_name } "
144- if not analyzer_path .endswith (".zarr" ):
145- link_url = "Only Zarr files are supported."
146- else :
147- link_url = EPHYSGUI_LINK_PREFIX .format (analyzer_path , recording_path , asset_name ).replace (
148- "#" , "%23"
149- )
150- links_url .append (link_url )
151- links = []
152- for link in links_url :
153- if "ephys_gui_app" in link :
154- links .append (format_link (link , text = "SpikeInterface-GUI" ))
155- else :
156- links .append (link )
157-
158- # Update the streams panel
159- streams_df = pd .DataFrame ({"Stream name" : stream_names , "Ephys GUI View" : links })
160- self .streams_panel .value = streams_df
181+ # Show loading pane while fetching streams in background
182+ self .streams_container [:] = [self ._streams_loading_pane ]
183+
184+ # Fetch streams data in a background thread to avoid blocking the UI
185+ def _on_streams_loaded (result_df ):
186+ self .streams_panel .value = result_df
187+ self .streams_container [:] = [self .streams_panel ]
188+
189+ self ._run_in_background (
190+ lambda : self ._fetch_streams_data (record , asset_name , location , db_version ),
191+ _on_streams_loaded ,
192+ )
161193 return
162194
163- # If no matching record is found, clear the streams panel
164- no_streams_text = f"No postprocessed streams..."
165- streams_df = pd .DataFrame ({"Stream name" : [no_streams_text ], "Ephys GUI View" : ["" ]})
166- self .streams_panel .value = streams_df
195+ # If no matching record is found, show message
196+ self .streams_container [:] = [pn .pane .Markdown (
197+ "*No postprocessed streams...*" ,
198+ styles = {"background-color" : "#f5f5f5" , "padding" : "20px" , "border-radius" : "5px" },
199+ )]
200+
201+ def _fetch_streams_data (self , record , asset_name , location , db_version ):
202+ """Blocking helper that fetches postprocessed stream data (runs in a background thread)."""
203+ stream_names = self .search_options .get_postprocessed_streams (location )
204+ print (f"Found { len (stream_names )} postprocessed streams from { location } " )
205+ analyzer_base_location = record ["location" ]
206+ raw_asset = get_raw_asset_by_name (asset_name , version = db_version )[0 ]
207+ links_url = []
208+ for stream_name in stream_names :
209+ raw_stream_name = stream_name [: stream_name .find ("_recording" )]
210+ raw_asset_prefix = self .get_raw_asset_location (raw_asset ["location" ])
211+ print (f"Raw asset prefix: { raw_asset_prefix } " )
212+ if raw_asset_prefix is None :
213+ recording_path = ""
214+ else :
215+ recording_path = f"{ raw_asset_prefix } /{ raw_stream_name } .zarr"
216+ analyzer_path = f"{ analyzer_base_location } /postprocessed/{ stream_name } "
217+ if not analyzer_path .endswith (".zarr" ):
218+ link_url = "Only Zarr files are supported."
219+ else :
220+ link_url = EPHYSGUI_LINK_PREFIX .format (analyzer_path , recording_path , asset_name ).replace (
221+ "#" , "%23"
222+ )
223+ links_url .append (link_url )
224+ links = []
225+ for link in links_url :
226+ if "ephys_gui_app" in link :
227+ links .append (format_link (link , text = "SpikeInterface-GUI" ))
228+ else :
229+ links .append (link )
230+
231+ return pd .DataFrame ({"Stream name" : stream_names , "Ephys GUI View" : links })
167232
168233 def get_raw_asset_location (self , asset_location ):
169234 asset_without_s3 = asset_location [asset_location .find ("s3://" ) + 5 :]
@@ -196,10 +261,10 @@ def panel(self):
196261 self .refresh_button ,
197262 pn .layout .Divider (),
198263 pn .pane .Markdown ("## Search Results" , styles = {"text-align" : "left" }),
199- self .results_panel ,
264+ self .results_container ,
200265 pn .layout .Divider (),
201266 pn .pane .Markdown ("## Postprocessed Streams" , styles = {"text-align" : "left" }),
202- self .streams_panel ,
267+ self .streams_container ,
203268 min_width = 1500 ,
204269 styles = OUTER_STYLE ,
205270 align = "center" ,
@@ -212,16 +277,15 @@ def panel(self):
212277class SearchOptions (param .Parameterized ):
213278 """Search options for the Ephys Portal."""
214279
215- def __init__ (self , database_version = "v2" ):
280+ def __init__ (self , database_version = "v2" , defer_load = False ):
216281 """Initialize a search options object."""
217282 super ().__init__ ()
218283 self .database_version = database_version
284+ self .all_records = []
285+ self .df = pd .DataFrame (columns = ["name" , "subject_id" , "date" , "id" ])
219286
220- self .update_options ()
221-
222- # Sort by date if available
223- if not self .df .empty and "date" in self .df .columns :
224- self .df = self .df .sort_values (by = "date" , ascending = False )
287+ if not defer_load :
288+ self .update_options ()
225289
226290 def update_options (self ):
227291 # Get initial data
@@ -249,8 +313,10 @@ def update_options(self):
249313 except Exception as e :
250314 print (f"Error loading initial data: { e } ." )
251315
252- # Create DataFrame
316+ # Create DataFrame and sort by date if available
253317 self .df = pd .DataFrame (data , columns = ["name" , "subject_id" , "date" , "id" ])
318+ if not self .df .empty and "date" in self .df .columns :
319+ self .df = self .df .sort_values (by = "date" , ascending = False )
254320
255321 def get_postprocessed_streams (self , location ):
256322 """Get the postprocessed folders for a given location."""
0 commit comments