Skip to content

Commit

Permalink
Add disconnect warning to Notification (#5244)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Jul 14, 2023
1 parent f2cefe3 commit 99ad77f
Show file tree
Hide file tree
Showing 9 changed files with 131 additions and 15 deletions.
7 changes: 7 additions & 0 deletions doc/how_to/callbacks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ How to schedule tasks that run independently of any user visiting an application
How to safely modify Bokeh models to avoid running into issues with the Bokeh `Document` lock.
:::

:::{grid-item-card} {octicon}`link;2.5em;sd-mr-1 sd-animate-grow50` Connection Notifications
:link: notifications
:link-type: doc

How to add notifications when the application is ready and when it loses the server connection.
:::

::::

## Examples
Expand Down
26 changes: 26 additions & 0 deletions doc/how_to/callbacks/notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Add notifications on connect and disconnect

This guide addresses how to add notifications when the server connection is established and when it disconnects.

---

Panel includes a notification system that is enabled by default. The notification system also allows registering notification messages when the server connection is ready and when the server connection is lost. These can be configured by setting the `disconnect_notification` and `ready_notification`.

```python
import panel as pn

pn.extension(
disconnect_notification='Connection lost, try reloading the page!',
ready_notification='Application fully loaded.',
template='bootstrap'
)

slider = pn.widgets.IntSlider(name='Number', start=1, end=10, value=7)

pn.Column(
slider,
pn.bind(lambda n: '' * n, slider)
).servable(title='Connection Notifications')
```

![Connection notifications](https://assets.holoviz.org/panel/gifs/connection_notifications.gif)
24 changes: 21 additions & 3 deletions panel/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ class _config(_base_config):
design = param.ClassSelector(class_=None, is_instance=False, doc="""
The design system to use to style components.""")

disconnect_notification = param.String(doc="""
The notification to display to the user when the connection
to the server is dropped.""")

exception_handler = param.Callable(default=None, doc="""
General exception handler for events.""")

Expand Down Expand Up @@ -168,6 +172,10 @@ class _config(_base_config):
'pyinstrument', 'snakeviz', 'memray'], doc="""
The profiler engine to enable.""")

ready_notification = param.String(doc="""
The notification to display when the application is ready and
fully loaded.""")

reuse_sessions = param.Boolean(default=False, doc="""
Whether to reuse a session for the initial request to speed up
the initial page render. Note that if the initial page differs
Expand Down Expand Up @@ -325,14 +333,19 @@ def _set_thread_pool(self):
state._thread_pool = ThreadPoolExecutor(max_workers=threads)

@param.depends('notifications', watch=True)
def _enable_notifications(self):
def _setup_notifications(self):
from .io.notifications import NotificationArea
from .reactive import ReactiveHTMLMetaclass
if self.notifications and 'notifications' not in ReactiveHTMLMetaclass._loaded_extensions:
ReactiveHTMLMetaclass._loaded_extensions.add('notifications')
if not state.curdoc:
state._notification = NotificationArea()

@param.depends('disconnect_notification', 'ready_notification', watch=True)
def _enable_notifications(self):
if self.disconnect_notification or self.ready_notification:
self.notifications = True

@contextmanager
def set(self, **kwargs):
values = [(k, v) for k, v in self.param.values().items() if k != 'name']
Expand Down Expand Up @@ -360,7 +373,7 @@ def __setattr__(self, attr, value):
if not init or (attr.startswith('_') and attr.endswith('_')) or attr == '_validating':
return super().__setattr__(attr, value)
value = getattr(self, f'_{attr}_hook', lambda x: x)(value)
if attr in self._globals:
if attr in self._globals or self.param._TRIGGER:
super().__setattr__(attr if attr in self.param else f'_{attr}', value)
elif state.curdoc is not None:
if attr in self.param:
Expand All @@ -372,6 +385,9 @@ def __setattr__(self, attr, value):
if state.curdoc not in self._session_config:
self._session_config[state.curdoc] = {}
self._session_config[state.curdoc][attr] = value
watchers = self._param_watchers.get(attr, {}).get('value', [])
for w in watchers:
w.fn()
elif f'_{attr}' in self.param and hasattr(self, f'_{attr}_'):
validate_config(self, f'_{attr}', value)
super().__setattr__(f'_{attr}_', value)
Expand Down Expand Up @@ -639,7 +655,9 @@ def __call__(self, *args, **params):
newly_loaded = [arg for arg in args if arg not in panel_extension._loaded_extensions]
if state.curdoc and state.curdoc not in state._extensions_:
state._extensions_[state.curdoc] = []
if params.get('notifications') and 'notifications' not in args:
if params.get('ready_notification') or params.get('disconnect_notification'):
params['notifications'] = True
if params.get('notifications', config.notifications) and 'notifications' not in args:
args += ('notifications',)
for arg in args:
if arg == 'notifications' and 'notifications' not in params:
Expand Down
10 changes: 5 additions & 5 deletions panel/io/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ def write(self, log):


# Set up logging
session_filter = MultiSelect(name='Filter by session', options=[])
message_filter = TextInput(name='Filter by message')
level_filter = MultiSelect(name="Filter by level", options=["DEBUG", "INFO", "WARNING", "ERROR"])
app_filter = TextInput(name='Filter by app')

data = Data()
log_data_handler = LogDataHandler(data)
log_handler = logging.StreamHandler()
Expand All @@ -127,11 +132,6 @@ def write(self, log):
log_terminal = _LogTabulator(sizing_mode='stretch_both', min_height=400)
log_handler.setStream(log_terminal)

session_filter = MultiSelect(name='Filter by session', options=[])
message_filter = TextInput(name='Filter by message')
level_filter = MultiSelect(name="Filter by level", options=["DEBUG", "INFO", "WARNING", "ERROR"])
app_filter = TextInput(name='Filter by app')

def _textinput_filter(df, pattern, column):
if not pattern or df.empty:
return df
Expand Down
23 changes: 22 additions & 1 deletion panel/io/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import param

from bokeh.models import CustomJS

from ..config import config
from ..reactive import ReactiveHTML
from ..util import classproperty
Expand Down Expand Up @@ -41,6 +43,14 @@ def destroy(self) -> None:

class NotificationAreaBase(ReactiveHTML):

js_events = param.Dict(default={}, doc="""
A dictionary that configures notifications for specific Bokeh Document
events, e.g.:
{'connection_lost': {'type': 'error', 'message': 'Connection Lost!', 'duration': 5}}
will trigger a warning on the Bokeh ConnectionLost event.""")

notifications = param.List(item_type=Notification)

position = param.Selector(default='bottom-right', objects=[
Expand Down Expand Up @@ -71,6 +81,18 @@ def get_root(
preprocess: bool = True
) -> 'Model':
root = super().get_root(doc, comm, preprocess)

for event, notification in self.js_events.items():
doc.js_on_event(event, CustomJS(code=f"""
var config = {{
message: {notification['message']!r},
duration: {notification.get('duration', 0)},
notification_type: {notification['type']!r},
_destroyed: false
}}
notifications.data.notifications.push(config)
notifications.data.properties.notifications.change.emit()
""", args={'notifications': root}))
self._documents[doc] = root
return root

Expand Down Expand Up @@ -243,7 +265,6 @@ def demo(cls):
if (ntype.value === 'custom') {
config.background = color.color
}
console.log(config, ntype.value)
notifications.data.notifications.push(config)
notifications.data.properties.notifications.change.emit()
"""
Expand Down
7 changes: 6 additions & 1 deletion panel/io/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,12 @@ def notifications(self) -> NotificationArea | None:
from ..config import config
if config.notifications and self.curdoc and self.curdoc.session_context and self.curdoc not in self._notifications:
from .notifications import NotificationArea
self._notifications[self.curdoc] = notifications = NotificationArea()
js_events = {}
if config.ready_notification:
js_events['document_ready'] = {'type': 'success', 'message': config.ready_notification, 'duration': 3000}
if config.disconnect_notification:
js_events['connection_lost'] = {'type': 'error', 'message': config.disconnect_notification}
self._notifications[self.curdoc] = notifications = NotificationArea(js_events=js_events)
return notifications
elif self.curdoc is None or self.curdoc.session_context is None:
return self._notification
Expand Down
10 changes: 6 additions & 4 deletions panel/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,14 +314,16 @@ def test_server_session_info(port):

doc = list(html._documents.keys())[0]
session_context = param.Parameterized()
request = param.Parameterized()
request.arguments = {}
session_context.request = request
session_context._document = doc
session_context.id = sid
doc._session_context = weakref.ref(session_context)
state.curdoc = doc
state._init_session(None)
assert state.session_info['live'] == 1
with set_curdoc(doc):
state._init_session(None)
assert state.session_info['live'] == 1

state.curdoc = None
html._server_destroy(session_context)
state._destroy_session(session_context)
assert state.session_info['live'] == 0
Expand Down
37 changes: 37 additions & 0 deletions panel/tests/ui/io/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from panel.config import config
from panel.io.server import serve
from panel.io.state import state
from panel.pane import Markdown
from panel.template import BootstrapTemplate
from panel.widgets import Button

Expand Down Expand Up @@ -54,3 +55,39 @@ def callback(event):
page.click('.bk-btn')

expect(page.locator('.notyf__message')).to_have_text('MyError')


def test_ready_notification(page, port):
def app():
config.ready_notification = 'Ready!'
return Markdown('Ready app')

serve(app, port=port, threaded=True, show=False)

time.sleep(0.2)

page.goto(f"http://localhost:{port}")

expect(page.locator('.notyf__message')).to_have_text('Ready!')


def test_disconnect_notification(page, port):
def app():
config.disconnect_notification = 'Disconnected!'
button = Button(name='Stop server')
button.js_on_click(code="""
Bokeh.documents[0].event_manager.send_event({'event_name': 'connection_lost', 'publish': false})
""")
return button

serve(app, port=port, threaded=True, show=False)

time.sleep(0.2)

page.goto(f"http://localhost:{port}")

time.sleep(0.2)

page.click('.bk-btn')

expect(page.locator('.notyf__message')).to_have_text('Disconnected!')
2 changes: 1 addition & 1 deletion panel/viewable.py
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@ def server_doc(
if location:
self._add_location(doc, location, model)
if config.notifications and doc is state.curdoc:
notification_model = state.notifications._get_model(doc, model)
notification_model = state.notifications.get_root(doc)
notification_model.name = 'notifications'
doc.add_root(notification_model)
if config.browser_info and doc is state.curdoc:
Expand Down

0 comments on commit 99ad77f

Please sign in to comment.