mirror of
https://github.com/simonw/datasette.git
synced 2026-05-30 13:46:59 +02:00
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
This commit is contained in:
parent
8a315f3d7d
commit
4c635a1d99
15 changed files with 238 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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$",
|
||||
|
|
|
|||
95
datasette/default_debug_menu.py
Normal file
95
datasette/default_debug_menu.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
23
datasette/templates/debug.html
Normal file
23
datasette/templates/debug.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug{% endblock %}
|
||||
|
||||
{% block body_class %}debug{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Debug</h1>
|
||||
|
||||
{% if items %}
|
||||
<ul class="debug-items">
|
||||
{% for item in items %}
|
||||
<li>
|
||||
<a href="{{ urls.path(item.path) }}">{{ item.title }}</a>{% if item.description %}
|
||||
<p>{{ item.description }}</p>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No debug tools available.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <authentication_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)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ EXPECTED_PLUGINS = [
|
|||
"asgi_wrapper",
|
||||
"canned_queries",
|
||||
"database_actions",
|
||||
"debug_menu",
|
||||
"extra_body_script",
|
||||
"extra_css_urls",
|
||||
"extra_js_urls",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue