5555# example must be run as `python examples/ex_tsdataui.py`, not
5656# `panel serve examples/ex_tsdataui.py`.
5757#
58- # Two-layer approach:
59- # Layer 1 — manager registry (in-memory): UUID cookie → _MANAGER_REGISTRY
60- # lookup → reuse existing param.Parameterized manager → fresh DataUI wraps
61- # it. All param state (time_range, catalog, math refs) is already live.
62- # Layer 2 — diskcache (disk): After a server restart the registry is empty;
63- # diskcache restores picklable params into a newly created manager.
58+ # install_session_handler() patches Bokeh's per_app_patterns to set a
59+ # persistent UUID cookie on first visit. SessionManager provides a two-layer
60+ # store: in-memory registry (Layer 1) + diskcache (Layer 2, server-restart).
6461
6562import panel as pn
66- from uuid import uuid4
67- from bokeh .server .urls import per_app_patterns
68- from panel .io .server import DocHandler
63+ from dvue .session_persistence import (
64+ install_session_handler ,
65+ SessionManager ,
66+ snapshot as _snapshot ,
67+ restore as _restore ,
68+ )
6969
70+ install_session_handler ()
7071
71- class _SessionAwareDocHandler (DocHandler ):
72- """Sets a persistent 'dvue_user_id' UUID cookie on first visit and injects
73- it into the current request so session_key_func sees it immediately."""
74-
75- _COOKIE_NAME = "dvue_user_id"
76-
77- async def get (self , * args , ** kwargs ):
78- user_id = self .get_cookie (self ._COOKIE_NAME )
79- if not user_id :
80- user_id = uuid4 ().hex
81- self .set_cookie (self ._COOKIE_NAME , user_id , expires_days = 365 , path = "/" )
82- # Inject into request.cookies (Tornado SimpleCookie) so
83- # session_key_func can read it on the very first visit.
84- self .request .cookies [self ._COOKIE_NAME ] = user_id
85- await super ().get (* args , ** kwargs )
86-
87-
88- per_app_patterns [0 ] = (r"/?" , _SessionAwareDocHandler )
89-
90- # -- diskcache state store (server-restart fallback) -------------------------
91- #
92- # Panel already depends on diskcache (used by pn.state.as_cached), so it is
93- # always available in the Panel conda/pip environment.
94- #
95- # diskcache advantages over hand-rolled JSON:
96- # - file-locking: safe for concurrent multi-user writes
97- # - built-in TTL: entries expire automatically (no manual eviction needed)
98- # - cleaner API: no tmp-file dance
99- #
100- # IMPORTANT: diskcache stores plain picklable Python objects (dicts, lists,
101- # datetimes). It CANNOT store live Panel/HoloViews objects (pn.Tabs, hv.Overlay
102- # etc.) — those are not meaningfully picklable. Only the *inputs* that produce
103- # plots (selection indices, time range) are stored here.
104-
105- import diskcache
106-
107- _CACHE_DIR = Path (__file__ ).parent .parent / ".session_cache"
108- _SESSION_CACHE = diskcache .Cache (str (_CACHE_DIR ))
109- _TTL = 30 * 24 * 3600 # 30 days
110-
111-
112- def _load_state (user_id : str ) -> dict :
113- return _SESSION_CACHE .get (user_id , default = {})
114-
115-
116- def _save_state (user_id : str , state : dict ) -> None :
117- _SESSION_CACHE .set (user_id , state , expire = _TTL )
72+ _session_mgr = SessionManager (
73+ cookie_name = "dvue_user_id" ,
74+ cache_dir = Path (__file__ ).parent .parent / ".session_cache" ,
75+ persist = True ,
76+ )
11877
11978# %% -- [2] Station metadata and synthetic data generator ---------------------
12079STATIONS = [
@@ -372,20 +331,11 @@ def _make_plot_action(self):
372331MATH_REFS_SEARCH_MAP_FILE = Path (__file__ ).parent / "data" / "math_refs_search_map.yaml"
373332
374333
375- # %% -- [6] App registry ------------------------------------------------------
376- #
377- # user_id → {"mgr": manager, "ui": DataUI, "template": VanillaTemplate}
334+ # %% -- [6] Per-user catalog factory ------------------------------------------
378335#
379- # Panel widgets (pn.Tabs, pn.Row, hv.HoloViews panes …) maintain their Python-
380- # level state (.objects, param values) independently of any Bokeh Document.
381- # When template.servable() is called in a new session, Panel creates fresh
382- # Bokeh models for the new Document that mirror the current Python state —
383- # including all plot tabs already in _display_panel. No replay needed.
384- #
385- # Per-session work (URL/location sync) is re-registered via pn.state.onload
386- # because pn.state.location is bound to the current Bokeh Document.
387-
388- _APP_REGISTRY : dict = {} # user_id → {mgr, ui, template}
336+ # Each user gets an independent DataCatalog instance so math refs added by one
337+ # user do not affect others. Session registry and persistence are handled by
338+ # _session_mgr (dvue.SessionManager).
389339
390340
391341def _make_catalog ():
@@ -396,32 +346,6 @@ def _make_catalog():
396346 return cat
397347
398348
399- def _snapshot (mgr , ui ) -> dict :
400- """Picklable snapshot for diskcache (server-restart fallback only)."""
401- tr = getattr (mgr , "time_range" , None )
402- tbl = getattr (ui , "display_table" , None )
403- return {
404- "time_range" : (
405- [pd .Timestamp (tr [0 ]).isoformat (), pd .Timestamp (tr [1 ]).isoformat ()]
406- if tr else None
407- ),
408- "selection" : list (tbl .selection or []) if tbl is not None else [],
409- }
410-
411-
412- def _restore (mgr , saved : dict ) -> None :
413- """Apply diskcache params to a freshly created manager."""
414- tr = saved .get ("time_range" )
415- if tr :
416- try :
417- mgr .time_range = (
418- pd .Timestamp (tr [0 ]).to_pydatetime (),
419- pd .Timestamp (tr [1 ]).to_pydatetime (),
420- )
421- except Exception :
422- pass
423-
424-
425349# %% -- [7] App factory -------------------------------------------------------
426350#
427351# Called once per Bokeh session (every browser tab open / page load).
@@ -437,22 +361,24 @@ def _restore(mgr, saved: dict) -> None:
437361# Re-trigger plot for the saved selection (diskcache case only).
438362
439363def make_app ():
440- user_id = pn .state .cookies .get ("dvue_user_id" , "" )
364+ user_id = _session_mgr .current_user_id
365+ reg_key = _session_mgr .make_reg_key (user_id , "tsdataui" )
366+ entry = _session_mgr .get_entry (reg_key )
441367
442- if user_id and user_id in _APP_REGISTRY :
368+ if entry :
443369 # --- Registry hit: reuse existing objects --------------------------
444- entry = _APP_REGISTRY [user_id ]
445- mgr = entry ["mgr" ]
446- ui = entry ["ui" ]
447- tmpl = entry ["template" ]
370+ mgr = entry ["mgr" ]
371+ ui = entry ["ui" ]
372+ tmpl = entry ["template" ]
448373 # Re-register per-Document setup (location/URL sync binds to curdoc).
449374 pn .state .onload (lambda : (ui .setup_location_sync (), ui .setup_url_sync ()))
450375 tmpl .servable ()
451376 return
452377
453378 # --- No registry: new user or server restart ---------------------------
379+ saved = _session_mgr .load_state (user_id )
454380 mgr = ExampleTimeSeriesDataUIManager (_make_catalog ())
455- saved = _load_state ( user_id ) if user_id else {}
381+ mgr . show_reset_session_button = True # reset button appears in DataUI action row
456382 if saved :
457383 _restore (mgr , saved )
458384
@@ -462,12 +388,11 @@ def make_app():
462388 )
463389 tmpl .servable ()
464390
465- if user_id :
466- _APP_REGISTRY [user_id ] = {"mgr" : mgr , "ui" : ui , "template" : tmpl }
391+ _session_mgr .set_entry (reg_key , {"mgr" : mgr , "ui" : ui , "template" : tmpl })
467392
468393 # Restore selection + re-trigger plot (only meaningful after server restart
469394 # when diskcache had a saved selection; fresh users have no saved state).
470- sel = saved .get ("selection" , [])
395+ sel = saved .get ("selection" , []) if saved else []
471396
472397 def _on_load ():
473398 if sel and hasattr (ui , "display_table" ) and hasattr (ui , "_registered_actions" ):
@@ -481,8 +406,7 @@ def _on_load():
481406
482407 # Wire live-persistence watchers.
483408 def _save (event = None ):
484- if user_id :
485- _save_state (user_id , _snapshot (mgr , ui ))
409+ _session_mgr .save_state (user_id , _snapshot (mgr , ui ))
486410
487411 mgr .param .watch (_save , "time_range" )
488412 if hasattr (ui , "display_table" ):
0 commit comments