From 4c635a1d99b4cfd7c08a8176ba59005a897d2edd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 16:25:09 +0000 Subject: [PATCH] Add /-/debug hub page with debug_menu() plugin hook Replace the scattered debug tool links in the app menu with a single "Debug" link to a new /-/debug page. This page aggregates all debug tools using a new debug_menu() plugin hook, which plugins can implement to contribute DebugItem(title, description, path) entries. Hook implementations are responsible for their own permission checks, so the page only shows items the current actor can access. Core debug items (databases, plugins, versions, settings, permissions, etc.) are registered via default_debug_menu.py. https://claude.ai/code/session_01QE3BkTNRLvLEpLXy7ZDCeU --- datasette/__init__.py | 1 + datasette/app.py | 5 ++ datasette/default_debug_menu.py | 95 +++++++++++++++++++++++++++++++++ datasette/default_menu_links.py | 29 +--------- datasette/hookspecs.py | 5 ++ datasette/permissions.py | 7 +++ datasette/plugins.py | 1 + datasette/templates/debug.html | 23 ++++++++ datasette/views/special.py | 24 +++++++++ docs/plugin_hooks.rst | 47 ++++++++++++++++ tests/fixtures.py | 1 + tests/plugins/my_plugin.py | 14 ++++- tests/test_docs.py | 2 +- tests/test_html.py | 4 +- tests/test_plugins.py | 12 +++++ 15 files changed, 238 insertions(+), 32 deletions(-) create mode 100644 datasette/default_debug_menu.py create mode 100644 datasette/templates/debug.html diff --git a/datasette/__init__.py b/datasette/__init__.py index 47d2b4f6..93afd8e1 100644 --- a/datasette/__init__.py +++ b/datasette/__init__.py @@ -1,4 +1,5 @@ from datasette.permissions import Permission # noqa +from datasette.permissions import DebugItem # noqa from datasette.version import __version_info__, __version__ # noqa from datasette.events import Event # noqa from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa diff --git a/datasette/app.py b/datasette/app.py index 75f6071e..ad99796a 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -48,6 +48,7 @@ from .views.index import IndexView from .views.special import ( JsonDataView, PatternPortfolioView, + DebugMenuView, AuthTokenView, ApiExplorerView, CreateTokenView, @@ -1996,6 +1997,10 @@ class Datasette: AllowDebugView.as_view(self), r"/-/allow-debug$", ) + add_route( + wrap_view(DebugMenuView, self), + r"/-/debug$", + ) add_route( wrap_view(PatternPortfolioView, self), r"/-/patterns$", diff --git a/datasette/default_debug_menu.py b/datasette/default_debug_menu.py new file mode 100644 index 00000000..98bbf3e5 --- /dev/null +++ b/datasette/default_debug_menu.py @@ -0,0 +1,95 @@ +from datasette import hookimpl +from datasette.permissions import DebugItem + + +@hookimpl +def debug_menu(datasette, actor): + async def inner(): + items = [] + + # Items visible to anyone + items.append( + DebugItem( + title="Actor", + description="Current authenticated actor", + path="/-/actor", + ) + ) + + # Items requiring view-instance + if await datasette.allowed(action="view-instance", actor=actor): + items.extend( + [ + DebugItem( + title="Databases", + description="Connected databases", + path="/-/databases", + ), + DebugItem( + title="Installed plugins", + description="Plugins currently installed", + path="/-/plugins", + ), + DebugItem( + title="Version info", + description="Python, Datasette and SQLite versions", + path="/-/versions", + ), + DebugItem( + title="Settings", + description="Datasette configuration settings", + path="/-/settings", + ), + DebugItem( + title="Config", + description="Full configuration output", + path="/-/config", + ), + DebugItem( + title="Threads", + description="Active threads", + path="/-/threads", + ), + DebugItem( + title="Messages", + description="Debug the flash messaging system", + path="/-/messages", + ), + DebugItem( + title="Pattern portfolio", + description="Showcase of UI patterns and components", + path="/-/patterns", + ), + ] + ) + + # Items requiring permissions-debug + if await datasette.allowed(action="permissions-debug", actor=actor): + items.extend( + [ + DebugItem( + title="Permissions", + description="Debug and test permission checks", + path="/-/permissions", + ), + DebugItem( + title="Allow rules", + description="Debug actor_matches_allow logic", + path="/-/allow-debug", + ), + DebugItem( + title="Actions", + description="Available permission actions", + path="/-/actions", + ), + DebugItem( + title="Permission rules", + description="Permission rules from all sources", + path="/-/rules", + ), + ] + ) + + return items + + return inner diff --git a/datasette/default_menu_links.py b/datasette/default_menu_links.py index 85032387..4401889b 100644 --- a/datasette/default_menu_links.py +++ b/datasette/default_menu_links.py @@ -8,34 +8,7 @@ def menu_links(datasette, actor): return [] return [ - {"href": datasette.urls.path("/-/databases"), "label": "Databases"}, - { - "href": datasette.urls.path("/-/plugins"), - "label": "Installed plugins", - }, - { - "href": datasette.urls.path("/-/versions"), - "label": "Version info", - }, - { - "href": datasette.urls.path("/-/settings"), - "label": "Settings", - }, - { - "href": datasette.urls.path("/-/permissions"), - "label": "Debug permissions", - }, - { - "href": datasette.urls.path("/-/messages"), - "label": "Debug messages", - }, - { - "href": datasette.urls.path("/-/allow-debug"), - "label": "Debug allow rules", - }, - {"href": datasette.urls.path("/-/threads"), "label": "Debug threads"}, - {"href": datasette.urls.path("/-/actor"), "label": "Debug actor"}, - {"href": datasette.urls.path("/-/patterns"), "label": "Pattern portfolio"}, + {"href": datasette.urls.path("/-/debug"), "label": "Debug"}, ] return inner diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index b993fb61..851b3c39 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -137,6 +137,11 @@ def forbidden(datasette, request, message): """Custom response for a 403 forbidden error""" +@hookspec +def debug_menu(datasette, actor, request): + """Return a list of DebugItem objects for the /-/debug page""" + + @hookspec def menu_links(datasette, actor, request): """Links for the navigation menu""" diff --git a/datasette/permissions.py b/datasette/permissions.py index c48293ac..6473b516 100644 --- a/datasette/permissions.py +++ b/datasette/permissions.py @@ -122,6 +122,13 @@ class AllowedResource(NamedTuple): reason: str +@dataclass(frozen=True, kw_only=True) +class DebugItem: + title: str + path: str + description: str | None = None + + @dataclass(frozen=True, kw_only=True) class Action: name: str diff --git a/datasette/plugins.py b/datasette/plugins.py index e9818885..55d8a96e 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -27,6 +27,7 @@ DEFAULT_PLUGINS = ( "datasette.default_magic_parameters", "datasette.blob_renderer", "datasette.default_menu_links", + "datasette.default_debug_menu", "datasette.handle_exception", "datasette.forbidden", "datasette.events", diff --git a/datasette/templates/debug.html b/datasette/templates/debug.html new file mode 100644 index 00000000..98660645 --- /dev/null +++ b/datasette/templates/debug.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Debug{% endblock %} + +{% block body_class %}debug{% endblock %} + +{% block content %} +

Debug

+ +{% if items %} + +{% else %} +

No debug tools available.

+{% endif %} + +{% endblock %} diff --git a/datasette/views/special.py b/datasette/views/special.py index 57a3024d..1b5cde0e 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -89,6 +89,30 @@ class PatternPortfolioView(View): ) +class DebugMenuView(View): + async def get(self, request, datasette): + from datasette.plugins import pm + from datasette.utils import await_me_maybe + + items = [] + for hook in pm.hook.debug_menu( + datasette=datasette, + actor=request.actor, + request=request, + ): + extra_items = await await_me_maybe(hook) + if extra_items: + items.extend(extra_items) + return Response.html( + await datasette.render_template( + "debug.html", + request=request, + view_name="debug", + context={"items": items}, + ) + ) + + class AuthTokenView(BaseView): name = "auth_token" has_json_alternate = False diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 468b0ade..1b72503f 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1758,6 +1758,53 @@ This example will disable CSRF protection for that specific URL path: If any of the currently active ``skip_csrf()`` plugin hooks return ``True``, CSRF protection will be skipped for the request. +.. _plugin_hook_debug_menu: + +debug_menu(datasette, actor, request) +------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``actor`` - dictionary or None + The currently authenticated :ref:`actor `. + +``request`` - :ref:`internals_request` or None + The current HTTP request. This can be ``None`` if the request object is not available. + +This hook allows plugins to add items to the ``/-/debug`` page, which serves as a hub for all debug and diagnostic tools. + +The hook should return a list of ``DebugItem`` objects (importable from ``datasette.permissions``). Each item has a ``title``, an optional ``description``, and a ``path`` to link to. + +It can alternatively return an ``async def`` awaitable function which returns a list of ``DebugItem`` objects. + +Hook implementations are responsible for checking permissions before returning items - only return items that the current actor should be able to see. + +This example adds a debug item only if the actor has the ``view-instance`` permission: + +.. code-block:: python + + from datasette import hookimpl + from datasette.permissions import DebugItem + + + @hookimpl + def debug_menu(datasette, actor): + async def inner(): + if not await datasette.allowed( + action="view-instance", actor=actor + ): + return [] + return [ + DebugItem( + title="My debug tool", + description="Custom diagnostic page", + path="/-/my-debug-tool", + ) + ] + + return inner + .. _plugin_hook_menu_links: menu_links(datasette, actor, request) diff --git a/tests/fixtures.py b/tests/fixtures.py index 01c501f2..916a16bb 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -37,6 +37,7 @@ EXPECTED_PLUGINS = [ "asgi_wrapper", "canned_queries", "database_actions", + "debug_menu", "extra_body_script", "extra_css_urls", "extra_js_urls", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 96a8b4d7..b2ff0b4e 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -2,7 +2,7 @@ import asyncio from datasette import hookimpl from datasette.facets import Facet from datasette import tracer -from datasette.permissions import Action +from datasette.permissions import Action, DebugItem from datasette.resources import DatabaseResource from datasette.utils import path_with_added_args from datasette.utils.asgi import asgi_send_json, Response @@ -354,6 +354,18 @@ def forbidden(datasette, request, message): return Response.redirect("/login?message=" + message) +@hookimpl +def debug_menu(datasette, actor, request): + if actor: + return [ + DebugItem( + title="Test debug item", + description="From test plugin", + path="/-/plugins", + ) + ] + + @hookimpl def menu_links(datasette, actor, request): if actor: diff --git a/tests/test_docs.py b/tests/test_docs.py index b94a6f23..4cfb0bef 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -65,7 +65,7 @@ def documented_views(): if first_word.endswith("View"): view_labels.add(first_word) # We deliberately don't document these: - view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView")) + view_labels.update(("PatternPortfolioView", "AuthTokenView", "ApiExplorerView", "DebugMenuView")) return view_labels diff --git a/tests/test_html.py b/tests/test_html.py index 8fad5764..dd68be57 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -955,8 +955,8 @@ def test_edit_sql_link_not_shown_if_user_lacks_permission(has_permission): "actor_id,should_have_links,should_not_have_links", [ (None, None, None), - ("test", None, ["/-/permissions"]), - ("root", ["/-/permissions", "/-/allow-debug"], None), + ("test", None, ["/-/debug"]), + ("root", ["/-/debug"], None), ], ) async def test_navigation_menu_links( diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 7c2180e8..c91b932d 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -927,6 +927,18 @@ async def test_hook_handle_exception_custom_response(ds_client, param): assert response.text == param +@pytest.mark.asyncio +async def test_hook_debug_menu(ds_client): + # Without authentication, no plugin debug items + response = await ds_client.get("/-/debug") + assert "Test debug item" not in response.text + + # With authentication, plugin debug items appear + response_2 = await ds_client.get("/-/debug?_bot=1") + assert "Test debug item" in response_2.text + assert "From test plugin" in response_2.text + + @pytest.mark.asyncio async def test_hook_menu_links(ds_client): def get_menu_links(html):