holoviz/panel · #8805
enh: Modernize the admin dashboard with panel.ui and add admin access control
doc/_static/images/admin_login.pngadded0 + / 0 −
GitHub publishes no diff for this file. View it on GitHub ↗
doc/_static/images/admin_logs.png0 + / 0 −
GitHub publishes no diff for this file. View it on GitHub ↗
doc/_static/images/admin_overview.png0 + / 0 −
GitHub publishes no diff for this file. View it on GitHub ↗
doc/_static/images/launch_profiler.png0 + / 0 −
GitHub publishes no diff for this file. View it on GitHub ↗
doc/_static/images/user_profiling.png0 + / 0 −
GitHub publishes no diff for this file. View it on GitHub ↗
doc/how_to/profiling/admin.md48 + / 1 −
@@ -12,7 +12,54 @@ panel serve my-app.py --admin When you have successfully enabled it you should be able to visit the `/admin` endpoint of your application, e.g. if you are serving locally on port 5006, visit `http://localhost:5006/admin`. You should now be greeted with the overview page, which provides some details about currently active sessions, running versions and resource usage (if `psutil` is installed): -<img src="../../_static/images/admin_overview.png" width="70%"></img>+<img src="../../_static/images/admin_overview.png" width="80%"></img>++The sidebar switches between the pages of the admin panel:++- Overview: The number of total and active sessions, the average time to render and the average session duration, the memory and CPU usage of the server process and the versions of the main packages.+- Timeline: The lifecycle of each session, from initialization and rendering to the events it processes, periodic callbacks it executes and log messages it emits.+- Launch Profiling: The profiling output recorded while each application initialized, available when a profiler is enabled with `--profiler`, see [Profile your Application](./profile).+- User Profiling: The profiling output of callbacks decorated with `pn.io.profile`, see [Profile your Application](./profile).+- Logs: The session log messages, which can be filtered and downloaded, see [View application logs](./logs).++Each page is created the first time you open it. The admin panel follows the theme, logo and title configured with [`--page-config`](../server/page_config), so it matches the branding of the index, login and error pages, and its theme toggle switches between light and dark mode. Serving the admin panel does not change the design of the applications served alongside it.++## Adding pages++Additional pages can be registered with `pn.config.admin_plugins`, a list of tuples of a page title and a function returning the component to render on it. The function is called once per admin session, when the page is first opened. A `--setup` script is a good place to register them:++```python+import panel as pn++def cache_info():+ return pn.ui.Typography(f'Cached objects: {len(pn.state.cache)}')++pn.config.admin_plugins.append(('Cache', cache_info))+```++## Password protecting the admin panel++The admin panel exposes logs and profiling output of every session, so on a shared deployment you will want to restrict access to it. The `--admin-password` argument protects only the admin panel with a password, which works without configuring authentication for the applications themselves:++```bash+panel serve my-app.py --admin --admin-password "my-admin-password"+```++Visiting `/admin` now redirects to a login page at `/admin/login`, while the applications remain publicly accessible:++<img src="../../_static/images/admin_login.png" width="60%"></img>++Once logged in, the logout button in the header ends the admin session. The password can also be supplied with the `PANEL_ADMIN_PASSWORD` environment variable, which keeps it out of the process list, or set with `pn.config.admin_password` when serving with `pn.serve`.++The login is stored in a signed `panel_admin` cookie valid for one day, or for `--oauth-expiry-days` when an OAuth provider is configured. It is signed with the `--cookie-secret` if one is configured, otherwise with a secret generated when the server starts, in which case restarting the server logs every admin out. Supply a `--cookie-secret` when serving with `--num-procs` or behind a load balancer, so that all processes accept the same cookie.++If the server already authenticates its users with `--basic-auth` or `--oauth-provider`, you can instead restrict the admin panel to particular users with `--admin-users` (or the comma separated `PANEL_ADMIN_USERS` environment variable):++```bash+panel serve my-app.py --admin --basic-auth credentials.json --cookie-secret my_super_safe_cookie_secret --admin-users alice bob+```++Any other authenticated user is shown an authorization error when visiting the admin panel. The two options can be combined, in which case the admin password is required in addition to logging in as one of the admin users. ## Changing the admin panel endpoint doc/how_to/profiling/logs.md2 + / 0 −
@@ -15,4 +15,6 @@ def get_clusters(x, y, n_clusters): <img src="../../_static/images/admin_logs.png" width="80%"></img> +The filters above the table narrow down the log messages by level, by session and by text contained in the app (the logger name) and the message. The text filters match literally and ignore case, and the session filter lists every session that appears in the log. Each admin session has its own filters, so narrowing the logs does not affect other admins. **Clear filters** resets them and **Download log** saves the currently filtered messages as a CSV file.+ ## Related Resourcesdoc/how_to/profiling/profile.md2 + / 2 −
@@ -15,13 +15,13 @@ The launch profiler profiles the execution time of the initialization of a parti - [`snakeviz`](https://jiffyclub.github.io/snakeviz/): SnakeViz is a browser based graphical viewer for the output of Python’s cProfile module and an alternative to using the standard library pstats module. - [`memray`](https://bloomberg.github.io/memray/): memray is a memory profiler for Python. It can track memory allocations in Python code, in native extension modules, and in the Python interpreter itself. -Once enabled the launch profiler will profile each application separately and provide the profiler output generated by the selected profiling engine.+Once enabled the launch profiler will profile each application separately and provide the profiler output generated by the selected profiling engine on the **Launch Profiling** page of the admin panel, with one tab per application. With `pyinstrument` the **Timeline** and **Show all** switches above the output switch to a timeline view and include frames from library code, with `memray` the **Reporter** selector switches between the flamegraph, table and tree reports. <img src="../../_static/images/launch_profiler.png" width="80%"></img> ## User profiling -In addition to profiling the launch step of an application it is often also important to get insight into the interactive performance of an application. For that reason Panel also provides the `pn.io.profile` decorator that can be added to any callback and will report the profiling results in the [`/admin` panel](../profiling/admin.md). The `profile` helper takes two arguments, the name to record the profiling results under and the profiling `engine` to use.+In addition to profiling the launch step of an application it is often also important to get insight into the interactive performance of an application. For that reason Panel also provides the `pn.io.profile` decorator that can be added to any callback and will report the profiling results on the **User Profiling** page of the [`/admin` panel](../profiling/admin.md), with one tab per profile name. The `profile` helper takes two arguments, the name to record the profiling results under and the profiling `engine` to use. ```python @pn.io.profile('clustering', engine='snakeviz')doc/how_to/server/commandline.md6 + / 0 −
@@ -129,6 +129,12 @@ options: The length of the session history to record. --warm Whether to execute scripts on startup to warm up the server. --admin Whether to add an admin panel.+ --admin-endpoint ADMIN_ENDPOINT+ Name to use for the admin endpoint.+ --admin-password ADMIN_PASSWORD+ Password required to access the admin panel, independent of the server authentication.+ --admin-users ADMIN_USERS [ADMIN_USERS ...]+ Users, as authenticated by --basic-auth or --oauth-provider, allowed to access the admin panel. --admin-log-level {debug,info,warning,error,critical} One of: debug (default), info, warning, error or critical --profiler PROFILER The profiler to use by default, e.g. pyinstrument, snakeviz or memray.panel/_templates/admin_login.htmladded41 + / 0 −
@@ -0,0 +1,41 @@+<!DOCTYPE html>+<html lang="en">+ <head>+ <meta charset="utf-8">+ <meta name="viewport" content="width=device-width, initial-scale=1">+ <title>{{ page.title or 'Panel' }} Admin | Login</title>+ <link rel="icon" href="{{ page.favicon or PANEL_CDN ~ 'images/favicon.ico' }}">+ {{ page.head }}+ </head>+ <body>+ <header class="pn-appbar">+ {{ page.brand((page.title or 'Panel') ~ ' Admin') }}+ </header>+ <main class="pn-main pn-main--center">+ <form class="pn-paper pn-dialog login-form" action="{{ login_endpoint }}" method="post">+ <div class="pn-dialog-header">+ {% if not page.logo %}+ <span class="pn-mark pn-mark--primary"></span>+ {% endif %}+ <h1 class="pn-title">Admin panel</h1>+ <p class="pn-subtitle">Enter the admin password to continue.</p>+ </div>+ {% if errormessage %}+ <div id="error-message" class="pn-alert pn-alert--error" role="alert">+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8"/></svg>+ <span class="pn-alert-message errormessage">{{ errormessage|escape }}</span>+ </div>+ {% endif %}+ <div class="pn-stack">+ {# Lets password managers associate the saved password with this form. #}+ <input type="text" name="username" value="admin" autocomplete="username" hidden>+ <div class="pn-field">+ <input id="password" name="password" type="password" class="pn-input" placeholder=" " autocomplete="current-password" autofocus required>+ <label class="pn-label" for="password">Password</label>+ </div>+ </div>+ <button class="pn-button pn-button--block" type="submit">Log in</button>+ </form>+ </main>+ </body>+</html>panel/command/serve.py39 + / 2 −
@@ -39,12 +39,15 @@ from ..auth import BasicAuthProvider, OAuthProvider from ..config import config+from ..io.admin_auth import mark_admin_application from ..io.document import _cleanup_doc from ..io.liveness import LivenessHandler from ..io.reload import record_modules, watch from ..io.resources import DIST_DIR from ..io.rest import REST_PROVIDERS-from ..io.server import INDEX_HTML, get_static_routes, set_curdoc+from ..io.server import (+ INDEX_HTML, admin_auth_patterns, get_static_routes, set_curdoc,+) from ..io.state import state from ..util import edit_readonly, fullpath @@ -305,6 +308,18 @@ class Serve(_BkServe): help = "Name to use for the admin endpoint.", default = None )),+ ('--admin-password', Argument(+ action = 'store',+ type = str,+ help = "Password required to access the admin panel, independent of the server authentication.",+ default = None+ )),+ ('--admin-users', Argument(+ action = 'store',+ nargs = '+',+ help = "Users, as authenticated by --basic-auth or --oauth-provider, allowed to access the admin panel.",+ default = None+ )), ('--admin-log-level', Argument( action = 'store', default = None,@@ -580,7 +595,21 @@ def setup_file(): self._admin_path = admin_path config._admin = True- app = Application(FunctionHandler(admin_panel))+ if args.admin_password:+ if os.environ.get('PANEL_ADMIN_PASSWORD'):+ raise ValueError(+ "Supply the admin password either using the environment "+ "variable PANEL_ADMIN_PASSWORD or as an explicit argument, not both."+ )+ config.admin_password = args.admin_password+ if args.admin_users:+ if os.environ.get('PANEL_ADMIN_USERS'):+ raise ValueError(+ "Supply the admin users either using the environment "+ "variable PANEL_ADMIN_USERS or as an explicit argument, not both."+ )+ config.admin_users = args.admin_users+ app = mark_admin_application(Application(FunctionHandler(admin_panel))) unused_timeout = args.check_unused_sessions or 15000 state._admin_context = AdminApplicationContext( app, unused_timeout=unused_timeout, url=admin_path@@ -803,6 +832,13 @@ def setup_file(): elif args.oauth_jwt_user: config.oauth_jwt_user = args.oauth_jwt_user + if args.admin and config.admin_users and 'auth_provider' not in kwargs:+ raise ValueError(+ "Restricting the admin panel to --admin-users requires server "+ "authentication, enable it with --basic-auth or --oauth-provider "+ "or protect the admin panel with --admin-password instead."+ )+ if config.cookie_path: kwargs['cookie_path'] = config.cookie_path @@ -835,6 +871,7 @@ def _tornado_routes(self, args, kwargs): if args.admin: patterns.extend(self._admin_patterns())+ patterns.extend(admin_auth_patterns(self._admin_path)) if args.rest_session_info: patterns.extend(REST_PROVIDERS['param'](self._files, 'rest'))panel/config.py21 + / 2 −
@@ -264,6 +264,14 @@ class _config(_base_config): _admin_endpoint = param.String(default=None, doc="Name to use for the admin endpoint.") + _admin_password = param.String(default=None, allow_None=True, doc="""+ Password required to access the admin panel, entered on its own+ login page and independent of the server authentication.""")++ _admin_users = param.List(default=[], item_type=str, doc="""+ Users, as identified by the server authentication, allowed to+ access the admin panel. If empty, every user may access it.""")+ _admin_log_level: t.Literal[ 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' ] = param.Selector(@@ -403,8 +411,8 @@ class _config(_base_config): 'oauth_secret', 'oauth_jwt_user', 'oauth_redirect_uri', 'oauth_encryption_key', 'oauth_extra_params', 'npm_cdn', 'layout_compatibility', 'oauth_refresh_tokens', 'oauth_guest_endpoints',- 'oauth_optional', 'admin', 'index_titles', 'disable_validation',- 'pyodide_cdn_root', 'page_config'+ 'oauth_optional', 'admin', 'admin_password', 'admin_users',+ 'index_titles', 'disable_validation', 'pyodide_cdn_root', 'page_config' } _truthy = ['True', 'true', '1', True, 1]@@ -612,6 +620,17 @@ def admin(self): def admin_endpoint(self): return os.environ.get('PANEL_ADMIN_ENDPOINT', self._admin_endpoint) + @property+ def admin_password(self):+ return os.environ.get('PANEL_ADMIN_PASSWORD', self._admin_password) or None++ @property+ def admin_users(self):+ if 'PANEL_ADMIN_USERS' in os.environ:+ users = os.environ['PANEL_ADMIN_USERS'].split(',')+ return [user.strip() for user in users if user.strip()]+ return self._admin_users+ @property def admin_log_level(self): admin_log_level = os.environ.get('PANEL_ADMIN_LOG_LEVEL', self._admin_log_level)panel/io/admin.py311 + / 144 −
@@ -1,6 +1,8 @@ from __future__ import annotations import datetime as dt+import functools+import io import logging import os import sys@@ -18,44 +20,71 @@ from bokeh.plotting import ColumnDataSource, figure from ..config import config, panel_extension as extension-from ..depends import bind-from ..layout import (- Accordion, Column, FlexBox, Row, Tabs,-)-from ..pane import HTML, Bokeh-from ..template import FastListTemplate-from ..widgets import (- Button, MultiSelect, Tabulator, TextInput,-)-from ..widgets.indicators import Trend+from ..widgets import Tabulator from .logging import ( LOG_SESSION_CREATED, LOG_SESSION_DESTROYED, LOG_SESSION_LAUNCHING, panel_logger, ) from .notebook import push_notebook+from .pages import page_theme from .profile import profiling_tabs from .server import set_curdoc from .state import state if t.TYPE_CHECKING:+ from types import ModuleType++ from bokeh.document import Document from psutil import Process + from ..viewable import Viewable+ PROCESSES: dict[int, Process] = {} +LOG_COLUMNS = ["datetime", "level", "app", "session", "message"]++LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]+ log_sessions: list = [] ++@functools.cache+def _ui() -> ModuleType:+ """+ Imports ``panel.ui`` without the global design switch its import+ performs, which would restyle every application served next to the+ admin panel. The package is dropped from ``sys.modules`` again so that+ an application importing it still gets the switch, its cached+ submodules keep the component classes identical.+ """+ if 'panel.ui' in sys.modules:+ return sys.modules['panel.ui']+ import panel++ design = param.Parameterized.__getattribute__(config, 'design')+ import panel.ui as ui+ param.Parameterized.__setattr__(config, 'design', design)+ del sys.modules['panel.ui']+ del panel.ui+ return ui+++def _destroyed(doc: Document | None) -> bool:+ """+ Whether an admin session's document has been destroyed. The watchers on+ shared state are removed in on_session_destroyed callbacks, which run in+ arbitrary order, so they can still fire for a destroyed document.+ """+ return doc is not None and doc.session_context is None++ class LogFilter(logging.Filter): def filter(self, record): if 'Session ' not in record.msg: return True- session_id = record.args[0]- if session_id in log_sessions:- return False- elif session_id not in session_filter.options:- session_filter.options = [session_id] + session_filter.options- return True+ return record.args[0] not in log_sessions class Data(param.Parameterized):@@ -77,15 +106,18 @@ def emit(self, record: logging.LogRecord): class _LogTabulator(Tabulator):+ """+ Collects the session log records shared by all admin sessions, each of+ which renders its own filtered view of the records.+ """ _update_defaults = {- "theme": "midnight", "layout": "fit_data_stretch", "show_index": False,- "sorters": [{'field': 'datetime', 'dir': 'dsc'}],+ "sorters": [{'field': 'datetime', 'dir': 'desc'}], "disabled": True, "pagination": "local",- "page_size": 18,+ "page_size": 20, } def __init__(self, **params):@@ -95,11 +127,9 @@ def __init__(self, **params): @staticmethod def _create_frame(data=None):- columns=["datetime", "level", "app", "session", "message"] if data is None:- return pd.DataFrame(columns=columns)- else:- return pd.Series(data, index=columns)+ return pd.DataFrame(columns=LOG_COLUMNS)+ return pd.DataFrame([data], columns=LOG_COLUMNS) def write(self, log): # Example of a log message:@@ -111,19 +141,14 @@ def write(self, log): app = s[3] session = int(s[6]) message = " ".join(s[7:])- df = self._create_frame([datetime, level, app, session, message])-- self.stream(df, follow=False) except Exception:- pass+ return+ # Streaming a DataFrame rather than a Series triggers the value+ # event the admin sessions watch.+ self.stream(self._create_frame([datetime, level, app, session, message]), follow=False) # Set up logging-session_filter = MultiSelect(label='Filter by session', options=[])-message_filter = TextInput(label='Filter by message')-level_filter = MultiSelect(label="Filter by level", options=["DEBUG", "INFO", "WARNING", "ERROR"])-app_filter = TextInput(label='Filter by app')- data = Data() log_data_handler = LogDataHandler(data) log_handler = logging.StreamHandler()@@ -139,32 +164,23 @@ def write(self, log): log_terminal = _LogTabulator(sizing_mode='stretch_both', min_height=400) log_handler.setStream(t.cast('t.TextIO', log_terminal)) -def _textinput_filter(df, pattern, column):- if not pattern or df.empty:- return df- return df[df[column].str.contains(pattern)].copy()--log_terminal.add_filter(level_filter, 'level')-log_terminal.add_filter(bind(_textinput_filter, pattern=app_filter, column='app'))-log_terminal.add_filter(session_filter, 'session')-log_terminal.add_filter(bind(_textinput_filter, pattern=message_filter, column='message'))---def _clear_log_filters(*events):- level_filter.value = []- app_filter.value = ""- session_filter.value = []- message_filter.value = "" --reset_filter = Button(label="Clear filters")-reset_filter.on_click(_clear_log_filters)---download_filename, download_button = log_terminal.download_menu(- text_kwargs={'label': 'Enter filename for logfile', 'value': 'log.csv'},- button_kwargs={'label': 'Download logfile'}-)+def _filter_logs(+ df: pd.DataFrame, levels: list[str], app: str, sessions: list[int], message: str+) -> pd.DataFrame:+ if df.empty:+ return df+ mask = pd.Series(True, index=df.index)+ if levels:+ mask &= df['level'].isin(levels)+ if sessions:+ mask &= df['session'].isin(sessions)+ # Matched literally, a regex would raise while the pattern is being typed.+ if app:+ mask &= df['app'].str.contains(app, case=False, regex=False)+ if message:+ mask &= df['message'].str.contains(message, case=False, regex=False)+ return df[mask] EVENT_TYPES = {@@ -173,7 +189,7 @@ def _clear_log_filters(*events): 'rendering': 'Orange', 'processing': 'DodgerBlue', 'periodic': 'Violet',- 'logging': 'white'+ 'logging': 'SlateGray' } def get_timeline(doc=None):@@ -299,6 +315,9 @@ def update_cds(new, nb=False): pass def schedule_cds_update(event):+ if _destroyed(doc):+ log_data_handler._data.param.unwatch(watcher)+ return new = event.new[-1] if doc: doc.add_next_tick_callback(partial(update_cds, new))@@ -311,21 +330,31 @@ def _unwatch_data(session_context): log_data_handler._data.param.unwatch(watcher) doc.on_session_destroyed(_unwatch_data) - bk_pane = Bokeh(p)+ bk_pane = _ui().Bokeh(p, sizing_mode='stretch_both', min_height=500, margin=0) return bk_pane def get_version_info():+ import panel_material_ui+ from panel import __version__- return HTML(f"""- <h4>- Panel Server running on following versions:- </h4>- <code>- Python {sys.version.split('|')[0]}</br>- Panel: {__version__}</br>- Bokeh: {bokeh.__version__}</br>- Param: {param.__version__}</br>- </code>""", width=300, height=300, margin=(0, 5))++ ui = _ui()+ versions = {+ 'Python': sys.version.split()[0],+ 'Panel': __version__,+ 'Bokeh': bokeh.__version__,+ 'Param': param.__version__,+ 'panel-material-ui': panel_material_ui.__version__,+ }+ chips = [+ ui.Chip(label=f'{name} {version}', variant='outlined')+ for name, version in versions.items()+ ]+ return ui.Card(+ ui.FlexBox(*chips, gap='8px', margin=0, sizing_mode='stretch_width'),+ title='Server versions', collapsible=False, sizing_mode='stretch_width',+ margin=(0, 0, 0, 0)+ ) def get_process(): import psutil@@ -341,15 +370,16 @@ def get_mem(): def get_cpu(): return pd.DataFrame([(time.time(), get_process().cpu_percent())], columns=['time', 'cpu']) -def get_process_info():- memory = Trend(- data=get_mem(), plot_x='time', plot_y='memory', plot_type='step',- name='Memory Usage (MB)', width=300, height=300- )- cpu = Trend(- data=get_cpu(), plot_x='time', plot_y='cpu', plot_type='step',- name='CPU Usage (%)', width=300, height=300+def _trend(data, plot_y, label):+ ui = _ui()+ return ui.Trend(+ data=data, plot_x='time', plot_y=plot_y, plot_type='step', label=label,+ height=180, sizing_mode='stretch_width', margin=(10, 15) )++def get_process_info():+ memory = _trend(get_mem(), 'memory', 'Memory Usage (MB)')+ cpu = _trend(get_cpu(), 'cpu', 'CPU Usage (%)') def update_stats(): memory.stream(get_mem()) cpu.stream(get_cpu())@@ -390,24 +420,20 @@ def get_session_data(): def get_session_info(doc=None): df = get_session_data()- total = Trend(- data=df[['time', 'total']], plot_x='time', plot_y='total', plot_type='step',- name='Total Sessions', width=300, height=300- )- active = Trend(- data=df[['time', 'live']], plot_x='time', plot_y='live', plot_type='step',- name='Active Sessions', width=300, height=300- )- render = Trend(- data=df[['time', 'render']], plot_x='time', plot_y='render', plot_type='step',- name='Avg. Time to Render (s)', width=300, height=300- )- duration = Trend(- data=df[['time', 'duration']], plot_x='time', plot_y='duration', plot_type='step',- name='Avg. Session Duration (s)', width=300, height=300- )+ total = _trend(df[['time', 'total']], 'total', 'Total Sessions')+ active = _trend(df[['time', 'live']], 'live', 'Active Sessions')+ render = _trend(df[['time', 'render']], 'render', 'Avg. Time to Render (s)')+ duration = _trend(df[['time', 'duration']], 'duration', 'Avg. Session Duration (s)') # Set up callbacks def update_session_info(event):+ if _destroyed(doc):+ state.param.unwatch(watcher)+ return+ # Destroying this session updates the session info after its models+ # have been cleaned up.+ session = event.new['sessions'].get(doc.session_context.id) if doc else None+ if session and session['ended']:+ return df = get_session_data() for trend in (total, active, render, duration): trend.data = df[[trend.plot_x, trend.plot_y]]@@ -419,74 +445,215 @@ def _unwatch_session_info(session_context): return total, active, render, duration def get_overview(doc=None):- layout = FlexBox(*get_session_info(doc), margin=0, sizing_mode='stretch_width')- info = get_version_info()+ ui = _ui()+ trends = list(get_session_info(doc)) try: import psutil # noqa except Exception:- layout.append(info)- return layout+ process_info = [ui.Alert(+ 'Install psutil to monitor the memory and CPU usage of the server.',+ severity='info', sizing_mode='stretch_width', margin=(0, 0, 0, 0)+ )] else:- layout.extend([*get_process_info(), info])- return layout---def log_component():- # Without this tabulator is empty after reload of website- log_terminal.param.trigger("value")-- return Column(- Accordion(- ('Filters & Download', Row(- level_filter,- app_filter,- session_filter,- message_filter,- Column(- download_filename,- download_button,- reset_filter,- ),- sizing_mode='stretch_width'- )),- active=[],- active_header_background='#444444',- header_background='#333333',- sizing_mode='stretch_width'+ trends.extend(get_process_info())+ process_info = []+ cards = [+ ui.Paper(trend, variant='outlined', margin=(0, 0, 0, 0), sizing_mode='stretch_width')+ for trend in trends+ ]+ # Full width items share the grid spacing, keeping all gaps equal.+ return ui.Grid(+ *(ui.Grid(card, size={'xs': 12, 'sm': 6, 'xl': 4}) for card in cards),+ *(ui.Grid(item, size=12) for item in [*process_info, get_version_info()]),+ container=True, spacing=2, margin=(0, 0, 0, 0), sizing_mode='stretch_width'+ )+++def log_component(doc: Document | None = None) -> Viewable:+ """+ A filterable view of the session logs with its own filter widgets, so+ that admin sessions do not share filter state.+ """+ ui = _ui()+ level_filter = ui.MultiChoice(+ label='Level', options=LOG_LEVELS, sizing_mode='stretch_width'+ )+ app_filter = ui.TextInput(label='App', sizing_mode='stretch_width')+ session_filter = ui.MultiChoice(+ label='Session', options=sorted(log_terminal.value['session'].unique().tolist()),+ sizing_mode='stretch_width'+ )+ message_filter = ui.TextInput(label='Message', sizing_mode='stretch_width')++ def filtered(df):+ return _filter_logs(+ df, level_filter.value, app_filter.value_input,+ session_filter.value, message_filter.value_input+ )++ table = ui.Tabulator(+ value=filtered(log_terminal.value), sizing_mode='stretch_both',+ min_height=400, margin=0, **_LogTabulator._update_defaults+ )++ def refilter(*events):+ table.value = filtered(log_terminal.value)++ level_filter.param.watch(refilter, 'value')+ session_filter.param.watch(refilter, 'value')+ app_filter.param.watch(refilter, 'value_input')+ message_filter.param.watch(refilter, 'value_input')++ def clear_filters(event):+ level_filter.value = []+ session_filter.value = []+ app_filter.value = app_filter.value_input = ''+ message_filter.value = message_filter.value_input = ''++ clear = ui.Button(+ label='Clear filters', icon='filter_alt_off', variant='outlined',+ on_click=clear_filters, margin=(5, 10)+ )+ download = ui.FileDownload(+ callback=lambda: io.StringIO(table.value.to_csv(index=False)),+ filename='panel_log.csv', label='Download log', icon='download',+ variant='outlined', margin=(5, 10)+ )++ def add_record(record):+ session = record['session'].iloc[0]+ if session not in session_filter.options:+ session_filter.options = sorted([*session_filter.options, session])+ rows = filtered(record)+ if rows.empty:+ return+ elif table.value.empty:+ table.value = rows+ else:+ table.stream(rows, follow=False)++ def schedule_record(event):+ if _destroyed(doc):+ log_terminal.param.unwatch(watcher)+ return+ # Capture the new row now, by the next tick more may have been logged.+ record = event.new.iloc[-1:]+ if doc:+ doc.add_next_tick_callback(partial(add_record, record))+ else:+ add_record(record)++ watcher = log_terminal.param.watch(schedule_record, 'value')+ if doc:+ doc.on_session_destroyed(lambda session_context: log_terminal.param.unwatch(watcher))++ filters = ui.Paper(+ ui.Grid(+ *(+ ui.Grid(widget, size={'xs': 12, 'sm': 6, 'lg': 3})+ for widget in (level_filter, app_filter, session_filter, message_filter)+ ),+ container=True, spacing=1, sizing_mode='stretch_width' ),- log_terminal,- sizing_mode='stretch_both'+ ui.Row(clear, download, margin=0),+ variant='outlined', margin=(0, 0, 15, 0), sizing_mode='stretch_width' )+ return ui.Column(filters, table, sizing_mode='stretch_both', margin=0)+++def _page_branding() -> dict[str, t.Any]:+ """+ Applies ``config.page_config`` so the admin panel shares the branding of+ the server pages. ``Page`` class defaults already apply on their own.+ """+ branding: dict[str, t.Any] = {}+ page_config = config.page_config+ if page_config.get('theme_config'):+ branding['theme_config'] = page_config['theme_config']+ if page_config.get('logo'):+ branding['logo'] = page_theme.logo+ if page_config.get('favicon'):+ branding['favicon'] = page_theme.favicon+ if page_config.get('site_url'):+ branding['site_url'] = page_config['site_url']+ if (dark_theme := page_theme.dark_theme) is not None:+ branding['dark_theme'] = dark_theme+ return branding+ def admin_template(doc):+ ui = _ui() extension('tabulator', 'terminal')+ # Classic components (Tabulator, Bokeh, Trend) follow the Material design+ # in this session only, see _ui.+ config.design = ui.MaterialUIDesign log_sessions.append(id(doc)) def _remove_log_session(session_context): log_sessions.remove(id(doc)) doc.on_session_destroyed(_remove_log_session) - # Add and remove admin panel app from log sessions list- # Set up admin panel- template = FastListTemplate(title='Admin Panel', theme='dark')- tabs = Tabs(- ('Overview', get_overview(doc)),- ('Timeline', get_timeline(doc)),- margin=0,- sizing_mode='stretch_both'- )+ sections: list[tuple[str, str, t.Callable[[], Viewable]]] = [+ ('Overview', 'dashboard', partial(get_overview, doc)),+ ('Timeline', 'timeline', partial(get_timeline, doc)),+ ] if config.profiler:- tabs.append(- ('Launch Profiling', profiling_tabs(state, r'^\/.*', None))+ sections.append(+ ('Launch Profiling', 'rocket_launch', partial(profiling_tabs, state, r'^\/.*', None)) )- tabs.extend([- ('User Profiling', profiling_tabs(state, None, r'^\/.*')),- ('Logs', log_component())+ sections.extend([+ ('User Profiling', 'speed', partial(profiling_tabs, state, None, r'^\/.*')),+ ('Logs', 'article', partial(log_component, doc)), ])- tabs.extend([- (name, plugin()) for name, plugin in config.admin_plugins- ])- template.main.append(tabs)- return template+ sections.extend(+ (name, 'extension', plugin) for name, plugin in config.admin_plugins+ )++ # Sections are built on first visit, so watchers and periodic callbacks+ # only run for the sections an admin actually opens.+ rendered: dict[int, Viewable] = {}+ def section(index: int) -> Viewable:+ if index not in rendered:+ label, _, build = sections[index]+ rendered[index] = ui.Column(+ ui.Typography(label, variant='h5', margin=(0, 0, 10, 0)),+ build(),+ sizing_mode='stretch_both',+ margin=0+ )+ return rendered[index]++ menu = ui.MenuList(+ items=[{'label': label, 'icon': icon} for label, icon, _ in sections],+ active=0, dense=False, sizing_mode='stretch_width'+ )+ main = ui.Column(section(0), sizing_mode='stretch_both', margin=(10, 15))++ def navigate(event):+ # MenuList reports the index path of the item, the menu is flat.+ index = event.new[0] if isinstance(event.new, tuple) and event.new else event.new+ if isinstance(index, int):+ main[:] = [section(index)]+ menu.param.watch(navigate, 'active')++ header = []+ if config.admin_password:+ logout = ui.IconButton(+ icon='logout', description='Log out of the admin panel', color='light'+ )+ # Resolved in the browser so the link survives proxies rewriting the path.+ logout.js_on_click(code=(+ "window.location.assign(window.location.pathname.replace(/\\/?$/, '/logout'))"+ ))+ header.extend([ui.HSpacer(), logout])++ return ui.Page(+ title=f"{page_theme.title or 'Panel'} Admin",+ header=header,+ sidebar=[menu],+ sidebar_width=240,+ main=[main],+ **_page_branding()+ ) def admin_panel(doc): with set_curdoc(doc):panel/io/admin_auth.pyadded160 + / 0 −
@@ -0,0 +1,160 @@+"""+Access control for the admin panel, shared by the Tornado and ASGI servers.++Two independent checks apply. ``config.admin_users`` restricts the admin+panel to users authenticated by the server's auth provider, while+``config.admin_password`` requires a password entered on the admin panel's+own login page and therefore also works without server authentication.+"""+from __future__ import annotations++import hashlib+import hmac+import secrets+import time+import typing as t++from http.cookies import SimpleCookie++from ..config import config+from .resources import CDN_DIST, _env++if t.TYPE_CHECKING:+ from bokeh.application import Application++ADMIN_COOKIE = 'panel_admin'++LOGIN_ENDPOINT = 'login'++LOGOUT_ENDPOINT = 'logout'++INVALID_PASSWORD = 'Invalid password!'++ADMIN_LOGIN_TEMPLATE = _env.get_template('admin_login.html')++# Signs the admin cookie when no cookie secret is configured, so the cookie+# cannot be forged, at the cost of logging admins out on restart.+_PROCESS_SECRET = secrets.token_bytes(32)+++class AdminAccess(t.NamedTuple):+ """+ Outcome of checking a request against the admin access configuration.+ """++ allowed: bool+ login: bool = False+ error: str | None = None+++def mark_admin_application(app: Application) -> Application:+ """+ Marks an application as the admin panel, the servers enforce the admin+ access configuration on its routes.+ """+ app._panel_admin = True # type: ignore[attr-defined]+ return app+++def is_admin_application(app: t.Any) -> bool:+ return bool(getattr(app, '_panel_admin', False))+++def admin_protected() -> bool:+ return bool(config.admin_password or config.admin_users)+++def _signature(issued: str) -> str:+ secret = config.cookie_secret+ key = secret.encode('utf-8') if secret else _PROCESS_SECRET+ # Binding the password invalidates issued cookies when it changes.+ password = hashlib.sha256((config.admin_password or '').encode('utf-8')).hexdigest()+ return hmac.new(key, f'{issued}:{password}'.encode(), hashlib.sha256).hexdigest()+++def create_admin_token(now: float | None = None) -> str:+ issued = str(int(time.time() if now is None else now))+ return f'{issued}.{_signature(issued)}'+++def validate_admin_token(token: str | None) -> bool:+ if not token:+ return False+ issued, _, signature = token.partition('.')+ if not issued.isdigit() or not hmac.compare_digest(signature, _signature(issued)):+ return False+ return time.time() - int(issued) < config.oauth_expiry * 86400+++def validate_admin_password(password: str) -> bool:+ expected = config.admin_password+ if not expected:+ return False+ return hmac.compare_digest(password.encode('utf-8'), expected.encode('utf-8'))+++def check_admin_access(user: str | None, token: str | None) -> AdminAccess:+ """+ Checks whether a request may access the admin panel.++ Parameters+ ----------+ user: str | None+ The user authenticated by the server's auth provider.+ token: str | None+ The value of the admin cookie.+ """+ users = config.admin_users+ if users and user not in users:+ return AdminAccess(+ False, error=f'User {user!r} is not authorized to access the admin panel.'+ )+ if config.admin_password and not validate_admin_token(token):+ return AdminAccess(False, login=True)+ return AdminAccess(True)+++def relative_endpoint(path: str, endpoint: str) -> str:+ """+ The URL of an admin endpoint relative to the admin document at ``path``,+ which keeps redirects working behind proxies that rewrite the prefix.+ """+ if path.endswith('/'):+ return endpoint+ return f"{path.rsplit('/', 1)[-1]}/{endpoint}"+++def admin_document_url(login_path: str) -> str:+ """+ The URL of the admin document relative to its login page at+ ``login_path``. It omits the trailing slash, under which the document's+ relative resource URLs would not resolve.+ """+ segments = login_path.rstrip('/').split('/')+ return f'../{segments[-2]}' if len(segments) > 2 else '/'+++def admin_cookie(token: str | None, secure: bool = False) -> str:+ """+ The ``Set-Cookie`` header value storing ``token``, or clearing the+ cookie if it is None. The path is left to the browser, which scopes the+ cookie to the admin endpoint the login page is served under.+ """+ cookie: SimpleCookie = SimpleCookie()+ cookie[ADMIN_COOKIE] = token or ''+ morsel = cookie[ADMIN_COOKIE]+ morsel['httponly'] = True+ morsel['samesite'] = 'Lax'+ if secure:+ morsel['secure'] = True+ if token is None:+ morsel['max-age'] = 0+ else:+ morsel['max-age'] = int(config.oauth_expiry * 86400)+ return morsel.OutputString()+++def render_admin_login(error: str = '') -> str:+ return ADMIN_LOGIN_TEMPLATE.render(+ login_endpoint=LOGIN_ENDPOINT, errormessage=error, PANEL_CDN=CDN_DIST+ )panel/io/application.py2 + / 1 −
@@ -23,6 +23,7 @@ from bokeh.models import CustomJS from ..config import config+from .admin_auth import mark_admin_application from .document import _destroy_document from .handlers import ( FunctionHandler, MarkdownHandler, NotebookHandler, ScriptHandler,@@ -359,6 +360,6 @@ def build_applications( ) from .admin import admin_panel admin_handler = FunctionHandler(admin_panel)- apps['/admin'] = Application(admin_handler)+ apps['/admin'] = mark_admin_application(Application(admin_handler)) return appspanel/io/asgi.py92 + / 2 −
@@ -18,7 +18,7 @@ import typing as t from functools import partial-from urllib.parse import urljoin, urlparse+from urllib.parse import parse_qs, urljoin, urlparse from bokeh.server.asgi import BokehASGI, _ASGIWebSocketTransport from bokeh.server.core import SessionError@@ -28,8 +28,17 @@ from tornado.web import HTTPError from ..config import config+from .admin_auth import (+ ADMIN_COOKIE, INVALID_PASSWORD, LOGIN_ENDPOINT, LOGOUT_ENDPOINT,+ AdminAccess, admin_cookie, admin_document_url, admin_protected,+ check_admin_access, create_admin_token, is_admin_application,+ relative_endpoint, render_admin_login, validate_admin_password,+) from .application import build_applications-from .auth import PanelAuthPolicy, configure_auth, pop_auth_kwargs+from .auth import (+ PanelAuthPolicy, _is_websocket, _read_body, configure_auth,+ pop_auth_kwargs,+) from .document import _cleanup_doc, extra_socket_handlers from .logging import LOG_SESSION_CREATED from .reload import record_modules@@ -201,6 +210,13 @@ def __init__( if isinstance(auth_policy, PanelAuthPolicy): for pattern, handler in auth_policy.routes: self._add_route(pattern, handler)+ if config.admin_password:+ for app_path, context in self._core.applications.items():+ if not is_admin_application(context.application):+ continue+ base = re.escape(app_path.rstrip('/'))+ self._add_route(rf'{base}/{LOGIN_ENDPOINT}$', self._admin_login)+ self._add_route(rf'{base}/{LOGOUT_ENDPOINT}$', self._admin_logout) state._server_config[self] = server_config or {} #-----------------------------------------------------------------@@ -552,10 +568,68 @@ async def _static_dir( relative = f'{relative}/index.html' await self._serve_static(send, root, relative, head=head) + async def _admin_login(+ self, request: ServerRequest, receive: Receive, send: Send, params: dict[str, str]+ ) -> None:+ if not await self._check_method(request, send, ('GET', 'HEAD', 'POST')):+ return+ method = request.method.upper()+ if method != 'POST':+ page = render_admin_login()+ await self._respond(+ request, send, 200, page.encode(), 'text/html; charset=UTF-8',+ head=method == 'HEAD', extra_headers=[(b'cache-control', b'no-store')]+ )+ return+ form = parse_qs((await _read_body(receive)).decode('utf-8', errors='replace'))+ if validate_admin_password(form.get('password', [''])[0]):+ secure = request.protocol == 'https'+ cookie = admin_cookie(create_admin_token(), secure=secure)+ await self._respond(+ request, send, 302, b'', 'text/plain',+ extra_headers=[(b'set-cookie', cookie.encode()), (b'location', admin_document_url(request.path).encode())]+ )+ return+ page = render_admin_login(INVALID_PASSWORD)+ await self._respond(request, send, 401, page.encode(), 'text/html; charset=UTF-8')++ async def _admin_logout(+ self, request: ServerRequest, receive: Receive, send: Send, params: dict[str, str]+ ) -> None:+ if not await self._check_method(request, send):+ return+ await self._respond(+ request, send, 302, b'', 'text/plain',+ extra_headers=[+ (b'set-cookie', admin_cookie(None).encode()),+ (b'location', LOGIN_ENDPOINT.encode()),+ ]+ )+ #----------------------------------------------------------------- # Application handlers #----------------------------------------------------------------- + def _admin_access(self, context: ApplicationContext, request: ServerRequest) -> AdminAccess:+ if not admin_protected() or not is_admin_application(context.application):+ return AdminAccess(True)+ token = request.cookies.get(ADMIN_COOKIE)+ return check_admin_access(request.user, token.value if token else None)++ async def _authenticate(self, request: ServerRequest) -> bool:+ if not await super()._authenticate(request):+ return False+ # HTTP routes check admin access themselves, so that they can+ # redirect to the admin login page.+ if not _is_websocket(request) or not (route := self._route_path_of(request)):+ return True+ if (resolved := self._resolve_route(route)) is None:+ return True+ return self._admin_access(resolved[0], request).allowed++ def _route_path_of(self, request: ServerRequest) -> str | None:+ return self._route_path({'path': request.path, 'root_path': request.root_path})+ def _authorize( self, request: ServerRequest, session: bool = False ) -> tuple[bool | None, str | None, str | None]:@@ -570,6 +644,16 @@ async def _document( head = request.method.upper() == 'HEAD' if not await self._authenticate_http(request, send, head=head): return+ access = self._admin_access(context, request)+ if access.login:+ await self._redirect(request, send, relative_endpoint(request.path, LOGIN_ENDPOINT))+ return+ elif not access.allowed:+ page = render_auth_error(t.cast('str', access.error))+ await self._respond(+ request, send, 403, page.encode(), 'text/html; charset=UTF-8', head=head+ )+ return # Prevent the browser from caching the document page. The page embeds # a short-lived Bokeh session token; a cached page served after a@@ -681,6 +765,12 @@ async def _autoload( head = method == 'HEAD' if not await self._authenticate_http(request, send, head=head): return+ if not self._admin_access(context, request).allowed:+ await self._respond(+ request, send, 403, b'Not authorized to access the admin panel',+ 'text/plain', head=head+ )+ return element_id = self._argument(request, 'bokeh-autoload-element') if not element_id:panel/io/profile.py35 + / 48 −
@@ -108,16 +108,16 @@ def render_memray(name, sessions, show_memory_leaks=True, merge_threads=True, re return out.read(), "" def get_profiles(profilers, **kwargs):- from ..pane import HTML, Markdown+ from .admin import _ui+ ui = _ui() profiles = [] for (path, engine), sessions in profilers.items(): if not sessions: continue if engine == 'memray': src, style = render_memray(path, sessions, **kwargs) if kwargs.get('reporter', 'tree') not in ('flamegraph', 'table'):- from ..widgets import Terminal- term = Terminal(sizing_mode='stretch_both', margin=0, min_height=600)+ term = ui.Terminal(sizing_mode='stretch_both', margin=0, min_height=600) term.write(src) profiles.append((path, term)) continue@@ -127,15 +127,13 @@ def get_profiles(profilers, **kwargs): src, style = render_pyinstrument(sessions, **kwargs) elif engine == 'snakeviz': src, style = render_snakeviz(path, sessions)- html = HTML(+ html = ui.HTML( f'<iframe srcdoc="{src}" width="100%" height="100%" frameBorder="0" style="{style}"></iframe>', sizing_mode='stretch_both', margin=0, min_height=800 ) profiles.append((path, html))- if not profiles:- profiles.append(('', Markdown('No profiling output available'))) return profiles @@ -145,54 +143,43 @@ def get_sessions(allow=None, deny=None): def profiling_tabs(state, allow=None, deny=[]):- from ..layout import (- Accordion, Column, Row, Tabs,- )- from ..widgets import Checkbox, Select- tabs = Tabs(- *get_profiles(get_sessions(allow, deny)),- margin=(0, 5),+ from .admin import _ui+ ui = _ui()+ tabs = ui.Tabs(margin=0, sizing_mode='stretch_width')+ empty = ui.Alert(+ 'No profiling output available.', severity='info', margin=(0, 0, 15, 0), sizing_mode='stretch_width' )- def update_profiles(*args, **kwargs):- tabs[:] = get_profiles(- get_sessions(allow, deny), **kwargs-- )- state.param.watch(update_profiles, '_profiles')+ body = ui.Column(margin=0, sizing_mode='stretch_width') if config.profiler == 'pyinstrument':- def update_pyinstrument(*args):- update_profiles(timeline=timeline.value, show_all=show_all.value)- timeline = Checkbox(label='Enable timeline', margin=(5, 0))- timeline.param.watch(update_pyinstrument, 'value')- show_all = Checkbox(label='Show All', margin=(5, 0))- show_all.param.watch(update_pyinstrument, 'value')- config_panel = Row(- timeline,- show_all,- sizing_mode='stretch_width'- )+ options = {+ 'timeline': ui.Switch(label='Timeline'),+ 'show_all': ui.Switch(label='Show all'),+ } elif config.profiler == 'memray':- def update_memray(*args):- update_profiles(reporter=reporter.value)- reporter = Select(label='Reporter', options=['flamegraph', 'table', 'tree'], value='tree')- reporter.param.watch(update_memray, 'value')- config_panel = reporter+ options = {+ 'reporter': ui.Select(+ label='Reporter', options=['flamegraph', 'table', 'tree'], value='tree'+ )+ } else:- config_panel = Row(sizing_mode='stretch_width')- return Column(- Accordion(- ('Config', config_panel),- active=[],- active_header_background='#444444',- header_background='#333333',- sizing_mode='stretch_width',- margin=0- ),- tabs,- sizing_mode='stretch_width'- )+ options = {}++ def update_profiles(*args):+ kwargs = {name: widget.value for name, widget in options.items()}+ tabs[:] = profiles = get_profiles(get_sessions(allow, deny), **kwargs)+ body[:] = [tabs if profiles else empty]++ for widget in options.values():+ widget.param.watch(update_profiles, 'value')+ watcher = state.param.watch(update_profiles, '_profiles')+ if state.curdoc:+ state.curdoc.on_session_destroyed(lambda session_context: state.param.unwatch(watcher))+ update_profiles()+ if options:+ return ui.Column(ui.Row(*options.values(), margin=0), body, margin=0, sizing_mode='stretch_width')+ return body @contextmanagerpanel/io/server.py81 + / 0 −
@@ -64,6 +64,12 @@ from ..config import config from ..util import HTML_SANITIZER, edit_readonly, fullpath from ..util.warnings import warn+from .admin_auth import (+ ADMIN_COOKIE, INVALID_PASSWORD, LOGIN_ENDPOINT, LOGOUT_ENDPOINT,+ AdminAccess, admin_cookie, admin_document_url, admin_protected,+ check_admin_access, create_admin_token, is_admin_application,+ relative_endpoint, render_admin_login, validate_admin_password,+) from .application import build_applications from .auth import configure_auth from .document import ( # noqa@@ -831,6 +837,16 @@ def _set_request_route_context(handler: t.Any, *args, suffix: str = '', **kwargs request.route_params, request.app_path = _sanitize_route_context(raw_route_params, raw_app_path) +def _admin_access(handler: t.Any) -> AdminAccess:+ """+ Checks a request to an application route against the admin access+ configuration, which only restricts the admin application.+ """+ if not admin_protected() or not is_admin_application(handler.application_context.application):+ return AdminAccess(True)+ return check_admin_access(handler.current_user, handler.get_cookie(ADMIN_COOKIE))++ class DocHandler(LoginUrlMixin, BkDocHandler): @authenticated # type: ignore@@ -862,6 +878,15 @@ async def get(self, *args, **kwargs): # logout/login cycle would carry a stale token and the subsequent # WebSocket connection would fail (see e.g. holoviz/panel#8634). self.set_header("Cache-Control", "no-store")+ access = _admin_access(self)+ if access.login:+ self.redirect(relative_endpoint(self.request.path, LOGIN_ENDPOINT))+ return+ elif not access.allowed:+ self.set_status(403)+ self.set_header("Content-Type", 'text/html')+ self.write(self._render_auth_error(t.cast('str', access.error)))+ return prefix = self.application.prefix if prefix and self.request.path == prefix and not prefix.endswith('/'): query_string = self.request.query if self.request.query else ''@@ -944,6 +969,9 @@ class AutoloadJsHandler(BkAutoloadJsHandler): async def get(self, *args, **kwargs) -> None: _set_request_route_context(self, *args, suffix='/autoload.js', **kwargs)+ if not _admin_access(self).allowed:+ self.send_error(status_code=403, reason='Not authorized to access the admin panel')+ return element_id = self.get_argument("bokeh-autoload-element", default=None) if not element_id: self.send_error(status_code=400, reason='No bokeh-autoload-element query parameter')@@ -987,6 +1015,13 @@ async def prepare(self): ) self.set_status(403) self.finish()+ elif not _admin_access(self).allowed:+ logger.debug(+ "Rejecting unauthorized WebSocket connection to admin panel %r with 403.",+ self.request.path+ )+ self.set_status(403)+ self.finish() def open(self, *args, **kwargs): _set_request_route_context(self, *args, suffix='/ws', **kwargs)@@ -1024,6 +1059,49 @@ async def _async_open(self, token: str) -> None: (r'/?', DocHandler), ] +class AdminLoginHandler(RequestHandler):+ """+ Serves the password login page of the admin panel.+ """++ def get(self):+ self.set_header("Cache-Control", "no-store")+ self.write(render_admin_login())++ def post(self):+ if validate_admin_password(self.get_argument('password', '')):+ secure = self.request.protocol == 'https'+ self.add_header('Set-Cookie', admin_cookie(create_admin_token(), secure=secure))+ self.redirect(admin_document_url(self.request.path))+ return+ self.set_status(401)+ self.write(render_admin_login(INVALID_PASSWORD))+++class AdminLogoutHandler(RequestHandler):+ """+ Clears the admin cookie and returns to the admin login page.+ """++ def get(self):+ self.add_header('Set-Cookie', admin_cookie(None))+ self.redirect(LOGIN_ENDPOINT)+++def admin_auth_patterns(admin_path: str) -> list[tuple[str, type[RequestHandler]]]:+ """+ The routes of the admin login and logout pages, if the admin panel is+ password protected.+ """+ if not config.admin_password:+ return []+ admin_path = admin_path.rstrip('/')+ return [+ (f'{admin_path}/{LOGIN_ENDPOINT}', AdminLoginHandler),+ (f'{admin_path}/{LOGOUT_ENDPOINT}', AdminLogoutHandler),+ ]++ class RootHandler(LoginUrlMixin, BkRootHandler): """ Custom RootHandler that provides the CDN_DIST directory as a@@ -1548,6 +1626,9 @@ def flask_handler(slug, app): liveness_endpoint = 'liveness' if isinstance(liveness, bool) else liveness extra_patterns += [(rf"/{liveness_endpoint}", LivenessHandler, dict(applications=apps))] + if admin:+ extra_patterns += admin_auth_patterns('/admin')+ opts = dict(kwargs) if loop: asyncio.set_event_loop(loop.asyncio_loop)panel/tests/command/test_serve_asgi.py33 + / 0 −
@@ -236,6 +236,39 @@ def test_serve_asgi_admin(invoke_asgi): assert asgi.core._applications['/admin'] is state._admin_context +@pytest.fixture+def admin_access_cleanup():+ try:+ yield+ finally:+ config.param.update(_admin_password=None, _admin_users=[])+++def test_serve_asgi_admin_password(invoke_asgi, admin_access_cleanup):+ from starlette.testclient import TestClient++ asgi = _panel_asgi(invoke_asgi('--admin', '--admin-password', 'pw'))+ assert config.admin_password == 'pw'+ with TestClient(asgi) as client:+ r = client.get('/admin', follow_redirects=False)+ assert r.status_code == 302+ assert r.headers['location'] == 'admin/login'+ assert client.get('/admin/login').status_code == 200+++def test_serve_asgi_admin_users_requires_server_auth(invoke_asgi, admin_access_cleanup):+ with pytest.raises(ValueError, match='requires server authentication'):+ invoke_asgi('--admin', '--admin-users', 'alice')+++def test_serve_asgi_admin_users(invoke_asgi, admin_access_cleanup):+ invoke_asgi(+ '--admin', '--admin-users', 'alice', 'bob', '--basic-auth', 'pw',+ '--cookie-secret', 'secret'+ )+ assert config.admin_users == ['alice', 'bob']++ def test_serve_fastapi_wraps_panel_asgi(invoke_asgi): pytest.importorskip('fastapi') from fastapi import FastAPIpanel/tests/io/test_admin.pyadded374 + / 0 −
@@ -0,0 +1,374 @@+"""+Tests for the admin panel and its access control.+"""+import subprocess+import sys+import time++import pandas as pd+import pytest++from bokeh.document import Document++from panel.config import config+from panel.io.admin import (+ _filter_logs, _ui, admin_template, log_component, log_terminal,+)+from panel.io.admin_auth import (+ ADMIN_COOKIE, admin_cookie, admin_document_url, check_admin_access,+ create_admin_token, relative_endpoint, validate_admin_password,+ validate_admin_token,+)+from panel.io.state import set_curdoc, state+from panel.tests.util import serve_and_wait++# The admin panel renders panel.ui components, whose registration cannot be+# undone, so these tests have to run after the classic suite.+pytestmark = pytest.mark.usefixtures('panel_ui')++PASSWORD = 'admin-secret'++ADMIN_CONFIG = ('_admin', '_admin_password', '_admin_users', '_cookie_secret', '_page_config', 'admin_plugins')+++@pytest.fixture(autouse=True)+def admin_config_cleanup():+ values = {name: getattr(config, name) for name in ADMIN_CONFIG}+ try:+ yield+ finally:+ config.param.update(**values)+ # The admin panel records the full session history.+ state.session_info = {'total': 0, 'live': 0, 'sessions': {}}+++@pytest.fixture+def admin_password():+ config.admin_password = PASSWORD+ return PASSWORD+++def _log(session, message, level='INFO', app='panel.io.server'):+ log_terminal.write(f'2026-10-03 14:38:04,803 {level}: {app} - Session {session} {message}\n')+++#---------------------------------------------------------------------+# Admin panel+#---------------------------------------------------------------------++def test_ui_import_preserves_global_design():+ # Run in a fresh interpreter, the test session may have imported+ # panel.ui already.+ script = (+ "import panel as pn\n"+ "from panel.io.admin import _ui\n"+ "ui = _ui()\n"+ "assert pn.config.design is None, pn.config.design\n"+ "import panel.ui\n"+ "assert pn.config.design is panel.ui.MaterialUIDesign, pn.config.design\n"+ "assert panel.ui.Button is ui.Button\n"+ )+ subprocess.run([sys.executable, '-c', script], check=True, timeout=120)+++def test_filter_logs():+ df = pd.DataFrame([+ ['2026-10-03', 'INFO', 'panel.io.server', 1, 'rendered'],+ ['2026-10-03', 'DEBUG', 'panel.state', 2, 'logged "[x]"'],+ ], columns=['datetime', 'level', 'app', 'session', 'message'])+ assert len(_filter_logs(df, [], '', [], '')) == 2+ assert _filter_logs(df, ['DEBUG'], '', [], '')['session'].tolist() == [2]+ assert _filter_logs(df, [], 'IO.SERVER', [], '')['session'].tolist() == [1]+ assert _filter_logs(df, [], '', [2], '')['session'].tolist() == [2]+ # Patterns are literal, an incomplete regex must not raise+ assert _filter_logs(df, [], '', [], '"[x')['session'].tolist() == [2]+++def test_log_component_filters_and_streams_records():+ component = log_component()+ filters, table = component.objects+ level, _, session, _ = (grid.objects[0] for grid in filters.objects[0].objects)++ level.value = ['WARNING']+ assert table.value.empty or set(table.value['level']) == {'WARNING'}++ _log(9001, 'rendered', level='WARNING')+ _log(9001, 'created', level='INFO')+ assert table.value['message'].tolist()[-1:] == ['rendered']+ assert 9001 in session.options++ level.value = []+ assert {'rendered', 'created'} <= set(table.value['message'])+++def test_log_components_do_not_share_filters():+ first, second = log_component(), log_component()+ first_level = first.objects[0].objects[0].objects[0].objects[0]+ second_level = second.objects[0].objects[0].objects[0].objects[0]+ first_level.value = ['ERROR']+ assert second_level.value == []+++def test_admin_template_navigation():+ config.admin_plugins = [('Plugin', lambda: _ui().Markdown('Plugin content'))]+ doc = Document()+ with set_curdoc(doc):+ page = admin_template(doc)+ assert isinstance(page, _ui().Page)+ menu = page.sidebar[0]+ labels = [item['label'] for item in menu.items]+ assert labels == ['Overview', 'Timeline', 'User Profiling', 'Logs', 'Plugin']++ main = page.main[0]+ assert main[0][0].object == 'Overview'+ with set_curdoc(doc):+ menu.active = (4,)+ assert main[0][0].object == 'Plugin'+ assert main[0][1].object == 'Plugin content'+ with set_curdoc(doc):+ menu.active = (0,)+ assert main[0][0].object == 'Overview'+++def test_admin_template_applies_page_config():+ config.page_config = {'title': 'Acme', 'theme_config': {'palette': {'primary': {'main': '#ff0000'}}}}+ doc = Document()+ with set_curdoc(doc):+ page = admin_template(doc)+ assert page.title == 'Acme Admin'+ assert page.theme_config == {'palette': {'primary': {'main': '#ff0000'}}}+ assert page.header == []+++def test_admin_template_logout_button(admin_password):+ doc = Document()+ with set_curdoc(doc):+ page = admin_template(doc)+ assert page.header[-1].icon == 'logout'+++#---------------------------------------------------------------------+# Admin access+#---------------------------------------------------------------------++def test_admin_token_roundtrip(admin_password):+ assert validate_admin_token(create_admin_token())+ assert not validate_admin_token(None)+ assert not validate_admin_token('garbage')+ assert not validate_admin_token(create_admin_token(now=time.time() - 2 * 86400))+++def test_admin_token_invalidated_by_password_change(admin_password):+ token = create_admin_token()+ config.admin_password = 'other'+ assert not validate_admin_token(token)+++def test_admin_token_invalidated_by_cookie_secret_change(admin_password):+ config.cookie_secret = 'first'+ token = create_admin_token()+ config.cookie_secret = 'second'+ assert not validate_admin_token(token)+++def test_validate_admin_password(admin_password):+ assert validate_admin_password(PASSWORD)+ assert not validate_admin_password('wrong')+ config.admin_password = None+ assert not validate_admin_password('')+++def test_check_admin_access_unprotected():+ assert check_admin_access(None, None).allowed+++def test_check_admin_access_password(admin_password):+ access = check_admin_access(None, None)+ assert not access.allowed and access.login+ assert check_admin_access(None, create_admin_token()).allowed+++def test_check_admin_access_users():+ config.admin_users = ['alice']+ assert check_admin_access('alice', None).allowed+ access = check_admin_access('bob', None)+ assert not access.allowed and not access.login+ assert "'bob'" in access.error+++def test_check_admin_access_users_and_password(admin_password):+ config.admin_users = ['alice']+ assert not check_admin_access('bob', create_admin_token()).allowed+ assert check_admin_access('alice', None).login+ assert check_admin_access('alice', create_admin_token()).allowed+++def test_admin_users_env_var(monkeypatch):+ monkeypatch.setenv('PANEL_ADMIN_USERS', 'alice, bob,')+ assert config.admin_users == ['alice', 'bob']+++@pytest.mark.parametrize(('path', 'expected'), [+ ('/admin', 'admin/login'),+ ('/admin/', 'login'),+ ('/prefix/admin', 'admin/login'),+])+def test_relative_endpoint(path, expected):+ assert relative_endpoint(path, 'login') == expected+++@pytest.mark.parametrize(('path', 'expected'), [+ ('/admin/login', '../admin'),+ ('/prefix/admin/login', '../admin'),+])+def test_admin_document_url(path, expected):+ assert admin_document_url(path) == expected+++def test_admin_cookie():+ cookie = admin_cookie('token', secure=True)+ assert cookie.startswith(f'{ADMIN_COOKIE}=token')+ assert 'HttpOnly' in cookie and 'Secure' in cookie and 'SameSite=Lax' in cookie+ assert 'Path' not in cookie+ assert 'Max-Age=0' in admin_cookie(None)+++#---------------------------------------------------------------------+# ASGI+#---------------------------------------------------------------------++@pytest.fixture+def admin_client():+ pytest.importorskip('httpx')+ from starlette.testclient import TestClient++ from panel.io.asgi import build_asgi_app+ from panel.pane import Markdown++ apps = []++ def create(**kwargs):+ asgi = build_asgi_app({'/app': lambda: Markdown('# App')}, admin=True, **kwargs)+ apps.append(asgi)+ return TestClient(asgi)++ yield create+ for asgi in apps:+ state._server_config.pop(asgi, None)+++def _token(client, path):+ r = client.get(path)+ assert r.status_code == 200+ return r.text.split('"token":')[1].split('"')[1]+++def test_asgi_admin_unprotected(admin_client):+ with admin_client() as client:+ assert client.get('/admin').status_code == 200+ assert client.get('/admin/login').status_code == 404+++def test_asgi_admin_password_login(admin_client, admin_password):+ with admin_client() as client:+ r = client.get('/admin', follow_redirects=False)+ assert r.status_code == 302+ assert r.headers['location'] == 'admin/login'+ assert client.get('/app').status_code == 200++ r = client.get('/admin/login')+ assert r.status_code == 200+ assert 'name="password"' in r.text++ r = client.post('/admin/login', data={'password': 'wrong'}, follow_redirects=False)+ assert r.status_code == 401+ assert 'Invalid password' in r.text++ r = client.post('/admin/login', data={'password': PASSWORD}, follow_redirects=False)+ assert r.status_code == 302+ assert r.headers['location'] == '../admin'+ assert client.get('/admin').status_code == 200++ r = client.get('/admin/logout', follow_redirects=False)+ assert r.status_code == 302+ assert client.get('/admin', follow_redirects=False).status_code == 302+++def test_asgi_admin_autoload_requires_password(admin_client, admin_password):+ with admin_client() as client:+ r = client.get('/admin/autoload.js?bokeh-autoload-element=el')+ assert r.status_code == 403+++def test_asgi_admin_websocket_requires_password(admin_client, admin_password):+ from starlette.websockets import WebSocketDisconnect++ with admin_client() as client:+ client.post('/admin/login', data={'password': PASSWORD}, follow_redirects=False)+ token = _token(client, '/admin')+ with client.websocket_connect('/admin/ws', subprotocols=['bokeh', token]) as ws:+ assert 'ACK' in ws.receive_text()+ client.cookies.clear()+ with pytest.raises(WebSocketDisconnect) as excinfo:+ with client.websocket_connect('/admin/ws', subprotocols=['bokeh', token]):+ pass+ assert excinfo.value.code == 1008+++def test_asgi_admin_users(admin_client):+ config.admin_users = ['alice']+ with admin_client(basic_auth={'alice': 'pw', 'bob': 'pw'}, cookie_secret='secret') as client:+ client.post('/login', data={'username': 'bob', 'password': 'pw'}, follow_redirects=False)+ assert client.get('/app').status_code == 200+ r = client.get('/admin')+ assert r.status_code == 403+ assert 'not authorized to access the admin panel' in r.text++ client.cookies.clear()+ client.post('/login', data={'username': 'alice', 'password': 'pw'}, follow_redirects=False)+ assert client.get('/admin').status_code == 200+++#---------------------------------------------------------------------+# Tornado+#---------------------------------------------------------------------++def test_tornado_admin_password_login(admin_password):+ import requests++ from panel.pane import Markdown++ port = serve_and_wait({'app': Markdown('# App')}, admin=True)+ base = f'http://127.0.0.1:{port}'+ session = requests.Session()++ r = session.get(f'{base}/admin', allow_redirects=False, timeout=30)+ assert r.status_code == 302+ assert r.headers['location'] == 'admin/login'+ assert session.get(f'{base}/app', timeout=30).status_code == 200++ r = session.get(+ f'{base}/admin/ws', timeout=30, headers={+ 'Connection': 'Upgrade', 'Upgrade': 'websocket', 'Sec-WebSocket-Version': '13',+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',+ }+ )+ assert r.status_code == 403+ r = session.get(f'{base}/admin/autoload.js?bokeh-autoload-element=el', timeout=30)+ assert r.status_code == 403++ r = session.post(f'{base}/admin/login', data={'password': 'wrong'}, timeout=30)+ assert r.status_code == 401++ r = session.post(+ f'{base}/admin/login', data={'password': PASSWORD}, allow_redirects=False, timeout=30+ )+ assert r.status_code == 302+ assert r.headers['location'] == '../admin'+ assert ADMIN_COOKIE in session.cookies+ r = session.get(f'{base}/admin', allow_redirects=False, timeout=30)+ assert r.status_code == 200++ session.get(f'{base}/admin/logout', timeout=30)+ r = session.get(f'{base}/admin', allow_redirects=False, timeout=30)+ assert r.status_code == 302panel/tests/ui/io/test_admin.pyadded71 + / 0 −
@@ -0,0 +1,71 @@+import pytest++pytest.importorskip("playwright")++from playwright.sync_api import expect++from panel.config import config+from panel.io.state import state+from panel.tests.util import serve_and_wait++# The admin panel renders panel.ui components, whose registration cannot be+# undone, so these tests have to run after the classic suite.+pytestmark = [pytest.mark.ui, pytest.mark.usefixtures('panel_ui')]++PASSWORD = 'admin-secret'+++@pytest.fixture(autouse=True)+def admin_config_cleanup():+ values = {name: getattr(config, name) for name in ('_admin', '_admin_password')}+ try:+ yield+ finally:+ config.param.update(**values)+ state.session_info = {'total': 0, 'live': 0, 'sessions': {}}+++def _serve_admin(page, prefix=''):+ port = serve_and_wait({'app': '# App'}, admin=True, prefix=prefix)+ base = f'http://127.0.0.1:{port}{prefix.rstrip("/")}'+ page.goto(f'{base}/app')+ expect(page.locator('h1')).to_contain_text('App')+ return base+++def test_admin_navigation(page):+ base = _serve_admin(page)+ page.goto(f'{base}/admin')++ expect(page.get_by_role('heading', name='Overview')).to_be_visible()+ expect(page.get_by_text('Total Sessions')).to_be_visible()++ page.get_by_role('button', name='Logs').click()+ expect(page.get_by_role('heading', name='Logs')).to_be_visible()+ expect(page.locator('.tabulator-row').first).to_be_visible()++ page.get_by_role('button', name='Overview').click()+ expect(page.get_by_text('Total Sessions')).to_be_visible()+++@pytest.mark.parametrize('prefix', ['', '/prefix'])+def test_admin_password_login_and_logout(page, prefix):+ config.admin_password = PASSWORD+ base = _serve_admin(page, prefix)++ page.goto(f'{base}/admin')+ expect(page).to_have_url(f'{base}/admin/login')++ page.fill('#password', 'wrong')+ page.click('button[type=submit]')+ expect(page.locator('#error-message')).to_have_text('Invalid password!')++ page.fill('#password', PASSWORD)+ page.click('button[type=submit]')+ expect(page).to_have_url(f'{base}/admin')+ expect(page.get_by_role('heading', name='Overview')).to_be_visible()++ page.locator('button:has-text("logout")').click()+ expect(page).to_have_url(f'{base}/admin/login')+ page.goto(f'{base}/admin')+ expect(page).to_have_url(f'{base}/admin/login')