plain.admin 0.34.0__py3-none-any.whl → 0.35.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/admin/CHANGELOG.md +11 -0
- plain/admin/README.md +0 -4
- plain/admin/default_settings.py +0 -6
- plain/admin/middleware.py +1 -2
- plain/admin/templates/toolbar/exception_button.html +5 -0
- plain/admin/templates/toolbar/toolbar.html +4 -8
- plain/admin/toolbar.py +10 -6
- plain/admin/urls.py +0 -2
- {plain_admin-0.34.0.dist-info → plain_admin-0.35.0.dist-info}/METADATA +1 -6
- {plain_admin-0.34.0.dist-info → plain_admin-0.35.0.dist-info}/RECORD +12 -20
- plain/admin/querystats/README.md +0 -146
- plain/admin/querystats/__init__.py +0 -3
- plain/admin/querystats/core.py +0 -155
- plain/admin/querystats/middleware.py +0 -102
- plain/admin/querystats/urls.py +0 -10
- plain/admin/querystats/views.py +0 -74
- plain/admin/templates/querystats/querystats.html +0 -144
- plain/admin/templates/querystats/toolbar.html +0 -90
- plain/admin/templates/toolbar/querystats.html +0 -28
- {plain_admin-0.34.0.dist-info → plain_admin-0.35.0.dist-info}/WHEEL +0 -0
- {plain_admin-0.34.0.dist-info → plain_admin-0.35.0.dist-info}/licenses/LICENSE +0 -0
plain/admin/CHANGELOG.md
CHANGED
@@ -1,5 +1,16 @@
|
|
1
1
|
# plain-admin changelog
|
2
2
|
|
3
|
+
## [0.35.0](https://github.com/dropseed/plain/releases/plain-admin@0.35.0) (2025-07-18)
|
4
|
+
|
5
|
+
### What's changed
|
6
|
+
|
7
|
+
- The built-in QueryStats functionality has been completely removed from plain-admin in favor of the new OpenTelemetry-based observability tools in the `plain-observer` package ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0))
|
8
|
+
- QueryStats documentation has been removed from the admin README ([97fb69d](https://github.com/dropseed/plain/commit/97fb69d))
|
9
|
+
|
10
|
+
### Upgrade instructions
|
11
|
+
|
12
|
+
- Remove any querystats-related settings like `ADMIN_QUERYSTATS_IGNORE_URLS` from your configuration
|
13
|
+
|
3
14
|
## [0.34.0](https://github.com/dropseed/plain/releases/plain-admin@0.34.0) (2025-07-18)
|
4
15
|
|
5
16
|
### What's changed
|
plain/admin/README.md
CHANGED
plain/admin/default_settings.py
CHANGED
plain/admin/middleware.py
CHANGED
@@ -1,5 +1,4 @@
|
|
1
1
|
from .impersonate.middleware import ImpersonateMiddleware
|
2
|
-
from .querystats.middleware import QueryStatsMiddleware
|
3
2
|
|
4
3
|
|
5
4
|
class AdminMiddleware:
|
@@ -9,4 +8,4 @@ class AdminMiddleware:
|
|
9
8
|
self.get_response = get_response
|
10
9
|
|
11
10
|
def __call__(self, request):
|
12
|
-
return
|
11
|
+
return ImpersonateMiddleware(self.get_response)(request)
|
@@ -0,0 +1,5 @@
|
|
1
|
+
<button class="cursor-pointer text-amber-500 hover:text-amber-400" type="button" data-toolbar-tab="{{ panel.name }}">
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-4 h-4 bi bi-exclamation-triangle-fill" viewBox="0 0 16 16">
|
3
|
+
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
|
4
|
+
</svg>
|
5
|
+
</button>
|
@@ -58,17 +58,13 @@
|
|
58
58
|
</div>
|
59
59
|
<button type="button" data-plaintoolbar-expand class="flex-grow cursor-pointer"></button>
|
60
60
|
<div class="flex items-center space-x-4">
|
61
|
-
{% include "querystats/toolbar.html" %}
|
62
|
-
|
63
61
|
<div class="flex items-center space-x-3 transition-all">
|
64
62
|
|
65
|
-
{%
|
66
|
-
|
67
|
-
|
68
|
-
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
|
69
|
-
</svg>
|
70
|
-
</button>
|
63
|
+
{% for panel in panels %}
|
64
|
+
{% if panel.button_template_name %}
|
65
|
+
{{ panel.render_button() }}
|
71
66
|
{% endif %}
|
67
|
+
{% endfor %}
|
72
68
|
|
73
69
|
<a href="{{ url('admin:index') }}" class="hover:underline">Admin</a>
|
74
70
|
|
plain/admin/toolbar.py
CHANGED
@@ -49,6 +49,7 @@ class Toolbar:
|
|
49
49
|
class ToolbarPanel:
|
50
50
|
name: str
|
51
51
|
template_name: str
|
52
|
+
button_template_name: str = ""
|
52
53
|
|
53
54
|
def __init__(self, request):
|
54
55
|
self.request = request
|
@@ -63,6 +64,14 @@ class ToolbarPanel:
|
|
63
64
|
context = self.get_template_context()
|
64
65
|
return mark_safe(template.render(context))
|
65
66
|
|
67
|
+
def render_button(self):
|
68
|
+
"""Render the toolbar button for the minimized state."""
|
69
|
+
if not self.button_template_name:
|
70
|
+
return ""
|
71
|
+
template = Template(self.button_template_name)
|
72
|
+
context = self.get_template_context()
|
73
|
+
return mark_safe(template.render(context))
|
74
|
+
|
66
75
|
|
67
76
|
class _ToolbarPanelRegistry:
|
68
77
|
def __init__(self):
|
@@ -86,6 +95,7 @@ def register_toolbar_panel(panel_class):
|
|
86
95
|
class _ExceptionToolbarPanel(ToolbarPanel):
|
87
96
|
name = "Exception"
|
88
97
|
template_name = "toolbar/exception.html"
|
98
|
+
button_template_name = "toolbar/exception_button.html"
|
89
99
|
|
90
100
|
def __init__(self, request, exception):
|
91
101
|
super().__init__(request)
|
@@ -101,9 +111,3 @@ class _ExceptionToolbarPanel(ToolbarPanel):
|
|
101
111
|
class _RequestToolbarPanel(ToolbarPanel):
|
102
112
|
name = "Request"
|
103
113
|
template_name = "toolbar/request.html"
|
104
|
-
|
105
|
-
|
106
|
-
@register_toolbar_panel
|
107
|
-
class _QuerystatsToolbarPanel(ToolbarPanel):
|
108
|
-
name = "Queries"
|
109
|
-
template_name = "toolbar/querystats.html"
|
plain/admin/urls.py
CHANGED
@@ -2,7 +2,6 @@ from plain.http import ResponseRedirect
|
|
2
2
|
from plain.urls import Router, include, path
|
3
3
|
|
4
4
|
from .impersonate.urls import ImpersonateRouter
|
5
|
-
from .querystats.urls import QuerystatsRouter
|
6
5
|
from .views.base import AdminView
|
7
6
|
from .views.registry import registry
|
8
7
|
|
@@ -36,7 +35,6 @@ class AdminRouter(Router):
|
|
36
35
|
urls = [
|
37
36
|
path("search/", AdminSearchView, name="search"),
|
38
37
|
include("impersonate/", ImpersonateRouter),
|
39
|
-
include("querystats/", QuerystatsRouter),
|
40
38
|
include("", registry.get_urls()),
|
41
39
|
path("", AdminIndexView, name="index"),
|
42
40
|
]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: plain.admin
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.35.0
|
4
4
|
Summary: Admin dashboard and tools for Plain.
|
5
5
|
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
6
6
|
License-Expression: BSD-3-Clause
|
@@ -10,7 +10,6 @@ Requires-Dist: plain-auth<1.0.0
|
|
10
10
|
Requires-Dist: plain-htmx<1.0.0
|
11
11
|
Requires-Dist: plain-tailwind<1.0.0
|
12
12
|
Requires-Dist: plain<1.0.0
|
13
|
-
Requires-Dist: sqlparse>=0.2.2
|
14
13
|
Description-Content-Type: text/markdown
|
15
14
|
|
16
15
|
# plain.admin
|
@@ -203,7 +202,3 @@ TODO
|
|
203
202
|
## Impersonate
|
204
203
|
|
205
204
|
TODO
|
206
|
-
|
207
|
-
## Querystats
|
208
|
-
|
209
|
-
TODO
|
@@ -1,13 +1,13 @@
|
|
1
|
-
plain/admin/CHANGELOG.md,sha256=
|
2
|
-
plain/admin/README.md,sha256=
|
1
|
+
plain/admin/CHANGELOG.md,sha256=CzUGnvKjAs7OxSCAFcGan-DOPEPUA838hq5xRquRji4,2464
|
2
|
+
plain/admin/README.md,sha256=jWHnpYt7xyQlp6YiK8AcjpLLZEr4wXj1LW3d1NjT8Ro,4593
|
3
3
|
plain/admin/__init__.py,sha256=bPv9iftT8aLqBH6dDy-HTVXW66dQUhfIiEZ-LIUMC0Y,78
|
4
4
|
plain/admin/config.py,sha256=TDYmJe4UYmKw4bz0x5s9PkDa-X4V-9JoJlka162-J7M,676
|
5
5
|
plain/admin/dates.py,sha256=EEhcQhHt3-k6kE9yvPdH5X6EecmUQ259xywbDBec3Dg,10253
|
6
|
-
plain/admin/default_settings.py,sha256=
|
7
|
-
plain/admin/middleware.py,sha256=
|
6
|
+
plain/admin/default_settings.py,sha256=qMiGLMo4JtHcx0rLhiHNOYW_v7T49AyJvA3AD5laye8,87
|
7
|
+
plain/admin/middleware.py,sha256=ir6xkkwL5sSmUH7dawFUb4sKXJy6UBolB78_odWooAY,320
|
8
8
|
plain/admin/templates.py,sha256=0xgMQmJEbh5U45ZlN2f15Xs42Y2A_lSS-_wdMp1BeD4,854
|
9
|
-
plain/admin/toolbar.py,sha256=
|
10
|
-
plain/admin/urls.py,sha256=
|
9
|
+
plain/admin/toolbar.py,sha256=4TUdmNZNaQxDRR1xC22RyYIkhzKQW87o5Vm98gGRF7Y,3046
|
10
|
+
plain/admin/urls.py,sha256=FeH0iWKCIRB7qDn2Ty1if5cZX6t2azCRKCRcNQ6_tww,1205
|
11
11
|
plain/admin/assets/admin/admin.css,sha256=HsayPTAn0-pnwBR-fKkih9KrXUhM7jrWuReXwe5byHo,2662
|
12
12
|
plain/admin/assets/admin/admin.js,sha256=jI5u55YMvffibugVDf2QAZyrdahFsGLCciHyzFBCvmI,2571
|
13
13
|
plain/admin/assets/admin/list.js,sha256=SDmDpSqsbbgLeAaV6V6JKbI-nG7WdjCD3MqFeO4LS_0,1835
|
@@ -27,12 +27,6 @@ plain/admin/impersonate/permissions.py,sha256=N0EFshs0pgwFIAsK2MUgfnyhdb2rYheY_l
|
|
27
27
|
plain/admin/impersonate/settings.py,sha256=4wbWBN9eZIzei4fwkFLfw-_T5pvP_GG4l1lDdVpL_Co,193
|
28
28
|
plain/admin/impersonate/urls.py,sha256=s8bwi8qPueKCCYcLW75p-hPFkBKhm2AMi6AQKQcZsWc,304
|
29
29
|
plain/admin/impersonate/views.py,sha256=PzVmzhlS0LmbHgxdLhc2G27ltvk3zgmO-3QWNAcD-l0,807
|
30
|
-
plain/admin/querystats/README.md,sha256=MtogVsRuBU9LLyfG5zBC6TRxoAg-bUm8MbT1Zp0GwBM,3833
|
31
|
-
plain/admin/querystats/__init__.py,sha256=VmP1aQ5Pviq4Z3izCB8G9g0Weq-2SYR88UFNtwqAPpo,81
|
32
|
-
plain/admin/querystats/core.py,sha256=myHdmpUoJyvVHZ4knuJrrtF2TcTdHjqhLXq2Whyi4Qs,4456
|
33
|
-
plain/admin/querystats/middleware.py,sha256=Vmja3FzMuOcexkdttVPGdxAFJSUseCOenptKV1IlraU,3327
|
34
|
-
plain/admin/querystats/urls.py,sha256=H8wMpqKBnXqA8ZsdwdxTKQguNYJ0JsMRqqMunccBm2I,198
|
35
|
-
plain/admin/querystats/views.py,sha256=cXmHZtEgRwXNN5d9HsKBe5G_VnDgSNwpwoRaEvuJ7vo,2375
|
36
30
|
plain/admin/templates/admin/base.html,sha256=urUvdcgcZZWjevZSvkagXdcrUe5LYUeaWme7S2gckMU,8509
|
37
31
|
plain/admin/templates/admin/delete.html,sha256=lNuU2G-BR6TH6NUmh7VcvjnEuFeI84rwwk_oO1jkUq0,431
|
38
32
|
plain/admin/templates/admin/detail.html,sha256=AizpXs6HguFzwbk7JDbH8poJB5dM2CaVVaQ4FThAHaw,730
|
@@ -66,12 +60,10 @@ plain/admin/templates/elements/admin/SelectField.html,sha256=mEA7m9DPB5Xd2GF5fEt
|
|
66
60
|
plain/admin/templates/elements/admin/Submit.html,sha256=9HGqeDnAzhDaL3PM7WxS4Xf8Xu6G9sCx9kh2-4W5GF8,150
|
67
61
|
plain/admin/templates/elements/admin/Textarea.html,sha256=nCSaGa9t5A5Oj6ZPWW-jSJiGqI1NLPahhXJblq62QME,363
|
68
62
|
plain/admin/templates/elements/admin/TextareaField.html,sha256=K_vgEVIntg0SiMSTqR2c4sQEG0Ktp5wfY0hCCaQNgrA,251
|
69
|
-
plain/admin/templates/querystats/querystats.html,sha256=xJ6fU22aWFQq3wATmRo7vFakmq-x7P6Z3OdZ2aVYmSs,7514
|
70
|
-
plain/admin/templates/querystats/toolbar.html,sha256=ba4kNfeDcn6LrIiIetA8Iao9FHySYNyXw28RPDToYTk,4437
|
71
63
|
plain/admin/templates/toolbar/exception.html,sha256=4WcrcBTCuyO_Jket8aaMFEL17o3FN3pF2QLrP7Pr60o,937
|
72
|
-
plain/admin/templates/toolbar/
|
64
|
+
plain/admin/templates/toolbar/exception_button.html,sha256=6yIQa28z2DZciNeSHW9cfj35Gs7WvSp94oJ-rpYYdKU,547
|
73
65
|
plain/admin/templates/toolbar/request.html,sha256=VyxNpEISVYZtGkR4J0XiXkv8d3LticUldJYXGE-OQNg,2967
|
74
|
-
plain/admin/templates/toolbar/toolbar.html,sha256=
|
66
|
+
plain/admin/templates/toolbar/toolbar.html,sha256=XCzAQPFwzpGj2BSmhIewORaFyrftM2ubNU3SJJOqdxM,6273
|
75
67
|
plain/admin/views/__init__.py,sha256=nF6AENZ3Xxyi08OTRrF6e-HYBkZSFj7XBK2mVzMYqN4,846
|
76
68
|
plain/admin/views/base.py,sha256=S1oaMUXnMOwRozbn2K-tk9tL4BMimemfMagZD9QxrJw,3512
|
77
69
|
plain/admin/views/models.py,sha256=rD-rUJxz5D_efmeHAYivnRfyiH3BdHq6Qh-KDWWEpUI,6690
|
@@ -79,7 +71,7 @@ plain/admin/views/objects.py,sha256=eKL8A2B1ZMgTrCbTXnh6vCeju_HObxwetn_xc1vYlfY,
|
|
79
71
|
plain/admin/views/registry.py,sha256=Lxib4YSQCMHb_zACnLKymJakV8jCZPWYll7J8-aV9Xw,3712
|
80
72
|
plain/admin/views/types.py,sha256=ONMMdUoapgMoUVYgSIe-4YCdfvaVMQ4jgPWYiMo0pDk,178
|
81
73
|
plain/admin/views/viewsets.py,sha256=dqMlQ6kLn9iqd9BwBWAZT1S271wH1FdfM5HXbOgBMEw,1655
|
82
|
-
plain_admin-0.
|
83
|
-
plain_admin-0.
|
84
|
-
plain_admin-0.
|
85
|
-
plain_admin-0.
|
74
|
+
plain_admin-0.35.0.dist-info/METADATA,sha256=mfvROSgTPiE5tNXoLtIWac2U4F0nGTPBEaJVBQObigQ,4999
|
75
|
+
plain_admin-0.35.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
76
|
+
plain_admin-0.35.0.dist-info/licenses/LICENSE,sha256=cvKM3OlqHx3ijD6e34zsSUkPvzl-ya3Dd63A6EHL94U,1500
|
77
|
+
plain_admin-0.35.0.dist-info/RECORD,,
|
plain/admin/querystats/README.md
DELETED
@@ -1,146 +0,0 @@
|
|
1
|
-
# plain.querystats
|
2
|
-
|
3
|
-
On-page database query stats in development and production.
|
4
|
-
|
5
|
-
On each page, the query stats will display how many database queries were performed and how long they took.
|
6
|
-
|
7
|
-
[Watch on YouTube](https://www.youtube.com/watch?v=NX8VXxVJm08)
|
8
|
-
|
9
|
-
Clicking the stats in the toolbar will show the full SQL query log with tracebacks and timings.
|
10
|
-
This is even designed to work in production,
|
11
|
-
making it much easier to discover and debug performance issues on production data!
|
12
|
-
|
13
|
-

|
14
|
-
|
15
|
-
It will also point out duplicate queries,
|
16
|
-
which can typically be removed by using `select_related`,
|
17
|
-
`prefetch_related`, or otherwise refactoring your code.
|
18
|
-
|
19
|
-
## Installation
|
20
|
-
|
21
|
-
```python
|
22
|
-
# settings.py
|
23
|
-
INSTALLED_PACKAGES = [
|
24
|
-
# ...
|
25
|
-
"plain.admin.querystats",
|
26
|
-
]
|
27
|
-
|
28
|
-
MIDDLEWARE = [
|
29
|
-
"plain.sessions.middleware.SessionMiddleware",
|
30
|
-
"plain.auth.middleware.AuthenticationMiddleware",
|
31
|
-
|
32
|
-
"plain.admin.querystats.QueryStatsMiddleware",
|
33
|
-
# Put additional middleware below querystats
|
34
|
-
# ...
|
35
|
-
]
|
36
|
-
```
|
37
|
-
|
38
|
-
We strongly recommend using the plain-toolbar along with this,
|
39
|
-
but if you aren't,
|
40
|
-
you can add the querystats to your frontend templates with this include:
|
41
|
-
|
42
|
-
```html
|
43
|
-
{% include "querystats/button.html" %}
|
44
|
-
```
|
45
|
-
|
46
|
-
_Note that you will likely want to surround this with an if `DEBUG` or `is_admin` check._
|
47
|
-
|
48
|
-
To view querystats you need to send a POST request to `?querystats=store` (i.e. via a `<form>`),
|
49
|
-
and the template include is the easiest way to do that.
|
50
|
-
|
51
|
-
## Tailwind CSS
|
52
|
-
|
53
|
-
This package is styled with [Tailwind CSS](https://tailwindcss.com/),
|
54
|
-
and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind).
|
55
|
-
|
56
|
-
If you are using your own Tailwind implementation,
|
57
|
-
you can modify the "content" in your Tailwind config to include any Plain packages:
|
58
|
-
|
59
|
-
```js
|
60
|
-
// tailwind.config.js
|
61
|
-
module.exports = {
|
62
|
-
content: [
|
63
|
-
// ...
|
64
|
-
".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
|
65
|
-
],
|
66
|
-
// ...
|
67
|
-
}
|
68
|
-
```
|
69
|
-
|
70
|
-
If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
|
71
|
-
|
72
|
-
# plain.toolbar
|
73
|
-
|
74
|
-
The admin toolbar is enabled for every user who `is_admin`.
|
75
|
-
|
76
|
-

|
77
|
-
|
78
|
-
## Installation
|
79
|
-
|
80
|
-
Add `plaintoolbar` to your `INSTALLED_PACKAGES`,
|
81
|
-
and the `{% toolbar %}` to your base template:
|
82
|
-
|
83
|
-
```python
|
84
|
-
# settings.py
|
85
|
-
INSTALLED_PACKAGES += [
|
86
|
-
"plaintoolbar",
|
87
|
-
]
|
88
|
-
```
|
89
|
-
|
90
|
-
```html
|
91
|
-
<!-- base.template.html -->
|
92
|
-
{% load toolbar %}
|
93
|
-
<!doctype html>
|
94
|
-
<html lang="en">
|
95
|
-
<head>
|
96
|
-
...
|
97
|
-
</head>
|
98
|
-
<body>
|
99
|
-
{% toolbar %}
|
100
|
-
...
|
101
|
-
</body>
|
102
|
-
```
|
103
|
-
|
104
|
-
More specific settings can be found below.
|
105
|
-
|
106
|
-
## Tailwind CSS
|
107
|
-
|
108
|
-
This package is styled with [Tailwind CSS](https://tailwindcss.com/),
|
109
|
-
and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind).
|
110
|
-
|
111
|
-
If you are using your own Tailwind implementation,
|
112
|
-
you can modify the "content" in your Tailwind config to include any Plain packages:
|
113
|
-
|
114
|
-
```js
|
115
|
-
// tailwind.config.js
|
116
|
-
module.exports = {
|
117
|
-
content: [
|
118
|
-
// ...
|
119
|
-
".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
|
120
|
-
],
|
121
|
-
// ...
|
122
|
-
}
|
123
|
-
```
|
124
|
-
|
125
|
-
If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
|
126
|
-
|
127
|
-
## Tailwind CSS
|
128
|
-
|
129
|
-
This package is styled with [Tailwind CSS](https://tailwindcss.com/),
|
130
|
-
and pairs well with [`plain-tailwind`](https://github.com/plainpackages/plain-tailwind).
|
131
|
-
|
132
|
-
If you are using your own Tailwind implementation,
|
133
|
-
you can modify the "content" in your Tailwind config to include any Plain packages:
|
134
|
-
|
135
|
-
```js
|
136
|
-
// tailwind.config.js
|
137
|
-
module.exports = {
|
138
|
-
content: [
|
139
|
-
// ...
|
140
|
-
".venv/lib/python*/site-packages/plain*/**/*.{html,js}",
|
141
|
-
],
|
142
|
-
// ...
|
143
|
-
}
|
144
|
-
```
|
145
|
-
|
146
|
-
If you aren't using Tailwind, and don't intend to, open an issue to discuss other options.
|
plain/admin/querystats/core.py
DELETED
@@ -1,155 +0,0 @@
|
|
1
|
-
import datetime
|
2
|
-
import time
|
3
|
-
import traceback
|
4
|
-
from collections import Counter
|
5
|
-
from functools import cached_property
|
6
|
-
|
7
|
-
import sqlparse
|
8
|
-
|
9
|
-
IGNORE_STACK_FILES = [
|
10
|
-
"threading",
|
11
|
-
"concurrent/futures",
|
12
|
-
"functools.py",
|
13
|
-
"socketserver",
|
14
|
-
"wsgiref",
|
15
|
-
"gunicorn",
|
16
|
-
"whitenoise",
|
17
|
-
"sentry_sdk",
|
18
|
-
"querystats/core",
|
19
|
-
"plain/template/base",
|
20
|
-
"plain/models",
|
21
|
-
"plain/internal",
|
22
|
-
]
|
23
|
-
|
24
|
-
|
25
|
-
def pretty_print_sql(sql):
|
26
|
-
return sqlparse.format(sql, reindent=True, keyword_case="upper")
|
27
|
-
|
28
|
-
|
29
|
-
def get_stack():
|
30
|
-
return "".join(tidy_stack(traceback.format_stack()))
|
31
|
-
|
32
|
-
|
33
|
-
def tidy_stack(stack):
|
34
|
-
lines = []
|
35
|
-
|
36
|
-
skip_next = False
|
37
|
-
|
38
|
-
for line in stack:
|
39
|
-
if skip_next:
|
40
|
-
skip_next = False
|
41
|
-
continue
|
42
|
-
|
43
|
-
if line.startswith(' File "') and any(
|
44
|
-
ignore in line for ignore in IGNORE_STACK_FILES
|
45
|
-
):
|
46
|
-
skip_next = True
|
47
|
-
continue
|
48
|
-
|
49
|
-
lines.append(line)
|
50
|
-
|
51
|
-
return lines
|
52
|
-
|
53
|
-
|
54
|
-
class QueryStats:
|
55
|
-
def __init__(self, include_tracebacks):
|
56
|
-
self.queries = []
|
57
|
-
self.include_tracebacks = include_tracebacks
|
58
|
-
|
59
|
-
def __str__(self):
|
60
|
-
s = f"{self.num_queries} queries in {self.total_time_display}"
|
61
|
-
if self.duplicate_queries:
|
62
|
-
s += f" ({self.num_duplicate_queries} duplicates)"
|
63
|
-
return s
|
64
|
-
|
65
|
-
def __call__(self, execute, sql, params, many, context):
|
66
|
-
current_query = {"sql": sql, "params": params, "many": many}
|
67
|
-
start = time.monotonic()
|
68
|
-
|
69
|
-
result = execute(sql, params, many, context)
|
70
|
-
|
71
|
-
if self.include_tracebacks:
|
72
|
-
current_query["tb"] = get_stack()
|
73
|
-
|
74
|
-
# if many, then X times is len(params)
|
75
|
-
|
76
|
-
# current_query["result"] = result
|
77
|
-
|
78
|
-
current_query["duration"] = time.monotonic() - start
|
79
|
-
|
80
|
-
self.queries.append(current_query)
|
81
|
-
return result
|
82
|
-
|
83
|
-
@cached_property
|
84
|
-
def total_time(self):
|
85
|
-
return sum(q["duration"] for q in self.queries)
|
86
|
-
|
87
|
-
@staticmethod
|
88
|
-
def get_time_display(seconds):
|
89
|
-
if seconds < 0.01:
|
90
|
-
return f"{seconds * 1000:.0f} ms"
|
91
|
-
return f"{seconds:.2f} seconds"
|
92
|
-
|
93
|
-
@cached_property
|
94
|
-
def total_time_display(self):
|
95
|
-
return self.get_time_display(self.total_time)
|
96
|
-
|
97
|
-
@cached_property
|
98
|
-
def num_queries(self):
|
99
|
-
return len(self.queries)
|
100
|
-
|
101
|
-
# @cached_property
|
102
|
-
# def models(self):
|
103
|
-
# # parse table names from self.queries sql
|
104
|
-
# table_names = [x for x in [q['sql'].split(' ')[2] for q in self.queries] if x]
|
105
|
-
# models = connection.introspection.installed_models(table_names)
|
106
|
-
# return models
|
107
|
-
|
108
|
-
@cached_property
|
109
|
-
def duplicate_queries(self):
|
110
|
-
sqls = [q["sql"] for q in self.queries]
|
111
|
-
duplicates = {k: v for k, v in Counter(sqls).items() if v > 1}
|
112
|
-
return duplicates
|
113
|
-
|
114
|
-
@cached_property
|
115
|
-
def num_duplicate_queries(self):
|
116
|
-
# Count the number of "excess" queries by getting how many there
|
117
|
-
# are minus the initial one (and potentially only one required)
|
118
|
-
return sum(self.duplicate_queries.values()) - len(self.duplicate_queries)
|
119
|
-
|
120
|
-
def as_summary_dict(self):
|
121
|
-
return {
|
122
|
-
"summary": str(self),
|
123
|
-
"total_time": self.total_time,
|
124
|
-
"num_queries": self.num_queries,
|
125
|
-
"num_duplicate_queries": self.num_duplicate_queries,
|
126
|
-
}
|
127
|
-
|
128
|
-
def as_context_dict(self, request):
|
129
|
-
# If we don't create a dict, the instance of this class
|
130
|
-
# is lost before we can use it in the template
|
131
|
-
for query in self.queries:
|
132
|
-
# Add some useful display info
|
133
|
-
query["duration_display"] = self.get_time_display(query["duration"])
|
134
|
-
query["sql_display"] = pretty_print_sql(query["sql"])
|
135
|
-
duplicates = self.duplicate_queries.get(query["sql"], 0)
|
136
|
-
if duplicates:
|
137
|
-
query["duplicate_count"] = duplicates
|
138
|
-
|
139
|
-
return {
|
140
|
-
**self.as_summary_dict(),
|
141
|
-
"request": {
|
142
|
-
"path": request.path,
|
143
|
-
"method": request.method,
|
144
|
-
"unique_id": request.unique_id,
|
145
|
-
},
|
146
|
-
"timestamp": datetime.datetime.now().isoformat(),
|
147
|
-
"total_time_display": self.total_time_display,
|
148
|
-
"queries": self.queries,
|
149
|
-
}
|
150
|
-
|
151
|
-
def as_server_timing(self):
|
152
|
-
duration = self.total_time * 1000 # put in ms
|
153
|
-
duration = round(duration, 2)
|
154
|
-
description = str(self)
|
155
|
-
return f'querystats;dur={duration};desc="{description}"'
|
@@ -1,102 +0,0 @@
|
|
1
|
-
import json
|
2
|
-
import logging
|
3
|
-
import re
|
4
|
-
|
5
|
-
from plain.json import PlainJSONEncoder
|
6
|
-
from plain.models import db_connection
|
7
|
-
from plain.runtime import settings
|
8
|
-
|
9
|
-
from .core import QueryStats
|
10
|
-
|
11
|
-
try:
|
12
|
-
import psycopg
|
13
|
-
except ImportError:
|
14
|
-
psycopg = None
|
15
|
-
|
16
|
-
logger = logging.getLogger(__name__)
|
17
|
-
|
18
|
-
|
19
|
-
class QueryStatsJSONEncoder(PlainJSONEncoder):
|
20
|
-
def default(self, obj):
|
21
|
-
try:
|
22
|
-
return super().default(obj)
|
23
|
-
except TypeError:
|
24
|
-
if psycopg and isinstance(obj, psycopg.types.json.Json):
|
25
|
-
return obj.obj
|
26
|
-
elif psycopg and isinstance(obj, psycopg.types.json.Jsonb):
|
27
|
-
return obj.obj
|
28
|
-
else:
|
29
|
-
raise
|
30
|
-
|
31
|
-
|
32
|
-
class QueryStatsMiddleware:
|
33
|
-
def __init__(self, get_response):
|
34
|
-
self.get_response = get_response
|
35
|
-
self.ignore_url_patterns = [
|
36
|
-
re.compile(url) for url in settings.ADMIN_QUERYSTATS_IGNORE_URLS
|
37
|
-
]
|
38
|
-
|
39
|
-
def should_ignore_request(self, request):
|
40
|
-
for url in self.ignore_url_patterns:
|
41
|
-
if url.match(request.path):
|
42
|
-
return True
|
43
|
-
|
44
|
-
return False
|
45
|
-
|
46
|
-
def __call__(self, request):
|
47
|
-
"""
|
48
|
-
Enables querystats for the current request.
|
49
|
-
|
50
|
-
If DEBUG or an admin, then Server-Timing headers are always added to the response.
|
51
|
-
Full querystats are only stored in the session if they are manually enabled.
|
52
|
-
"""
|
53
|
-
|
54
|
-
if self.should_ignore_request(request):
|
55
|
-
return self.get_response(request)
|
56
|
-
|
57
|
-
def is_tracking():
|
58
|
-
return "querystats" in request.session
|
59
|
-
|
60
|
-
querystats = QueryStats(include_tracebacks=is_tracking())
|
61
|
-
|
62
|
-
with db_connection.execute_wrapper(querystats):
|
63
|
-
is_admin = self.is_admin_request(request)
|
64
|
-
|
65
|
-
if settings.DEBUG or is_admin:
|
66
|
-
with db_connection.execute_wrapper(querystats):
|
67
|
-
response = self.get_response(request)
|
68
|
-
|
69
|
-
if settings.DEBUG:
|
70
|
-
# TODO logging settings
|
71
|
-
logger.debug("Querystats: %s", querystats)
|
72
|
-
|
73
|
-
# Make current querystats available on the current page
|
74
|
-
# by using the server timing API which can be parsed client-side
|
75
|
-
response.headers["Server-Timing"] = querystats.as_server_timing()
|
76
|
-
|
77
|
-
if is_tracking() and querystats.num_queries > 0:
|
78
|
-
request.session["querystats"][request.unique_id] = json.dumps(
|
79
|
-
querystats.as_context_dict(request), cls=QueryStatsJSONEncoder
|
80
|
-
)
|
81
|
-
|
82
|
-
# Keep 30 requests max, in case it is left on by accident
|
83
|
-
if len(request.session["querystats"]) > 30:
|
84
|
-
del request.session["querystats"][
|
85
|
-
list(request.session["querystats"])[0]
|
86
|
-
]
|
87
|
-
|
88
|
-
# Did a deeper modification to the session dict...
|
89
|
-
request.session.modified = True
|
90
|
-
|
91
|
-
return response
|
92
|
-
|
93
|
-
else:
|
94
|
-
return self.get_response(request)
|
95
|
-
|
96
|
-
@staticmethod
|
97
|
-
def is_admin_request(request):
|
98
|
-
if getattr(request, "impersonator", None):
|
99
|
-
# Support for impersonation (still want the real admin user to see the querystats)
|
100
|
-
return request.impersonator and request.impersonator.is_admin
|
101
|
-
|
102
|
-
return hasattr(request, "user") and request.user and request.user.is_admin
|
plain/admin/querystats/urls.py
DELETED
plain/admin/querystats/views.py
DELETED
@@ -1,74 +0,0 @@
|
|
1
|
-
import json
|
2
|
-
|
3
|
-
from plain.auth.views import AuthViewMixin
|
4
|
-
from plain.http import ResponseRedirect
|
5
|
-
from plain.runtime import settings
|
6
|
-
from plain.views import TemplateView
|
7
|
-
|
8
|
-
|
9
|
-
class QuerystatsView(AuthViewMixin, TemplateView):
|
10
|
-
template_name = "querystats/querystats.html"
|
11
|
-
admin_required = True
|
12
|
-
|
13
|
-
def check_auth(self):
|
14
|
-
# Allow the view if we're in DEBUG
|
15
|
-
if settings.DEBUG:
|
16
|
-
return
|
17
|
-
|
18
|
-
super().check_auth()
|
19
|
-
|
20
|
-
def get_response(self):
|
21
|
-
response = super().get_response()
|
22
|
-
# So we can load it in the toolbar
|
23
|
-
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
24
|
-
return response
|
25
|
-
|
26
|
-
def get(self):
|
27
|
-
# Give an easy out if things get messed up
|
28
|
-
if (
|
29
|
-
"clear" in self.request.query_params
|
30
|
-
and "querystats" in self.request.session
|
31
|
-
):
|
32
|
-
del self.request.session["querystats"]
|
33
|
-
self.request.session.modified = True
|
34
|
-
|
35
|
-
return super().get()
|
36
|
-
|
37
|
-
def get_template_context(self):
|
38
|
-
context = super().get_template_context()
|
39
|
-
|
40
|
-
querystats = self.request.session.get("querystats", {})
|
41
|
-
|
42
|
-
for request_id in list(querystats.keys()):
|
43
|
-
try:
|
44
|
-
querystats[request_id] = json.loads(querystats[request_id])
|
45
|
-
except (json.JSONDecodeError, TypeError):
|
46
|
-
# If decoding fails, remove the entry from the dictionary
|
47
|
-
del querystats[request_id]
|
48
|
-
|
49
|
-
# Order them by timestamp
|
50
|
-
querystats = dict(
|
51
|
-
sorted(
|
52
|
-
querystats.items(),
|
53
|
-
key=lambda item: item[1].get("timestamp", ""),
|
54
|
-
reverse=True,
|
55
|
-
)
|
56
|
-
)
|
57
|
-
|
58
|
-
context["querystats"] = querystats
|
59
|
-
context["querystats_enabled"] = "querystats" in self.request.session
|
60
|
-
|
61
|
-
return context
|
62
|
-
|
63
|
-
def post(self):
|
64
|
-
querystats_action = self.request.data["querystats_action"]
|
65
|
-
|
66
|
-
if querystats_action == "enable":
|
67
|
-
self.request.session.setdefault("querystats", {})
|
68
|
-
elif querystats_action == "clear":
|
69
|
-
self.request.session["querystats"] = {}
|
70
|
-
elif querystats_action == "disable" and "querystats" in self.request.session:
|
71
|
-
del self.request.session["querystats"]
|
72
|
-
|
73
|
-
# Redirect back to the page that submitted the form
|
74
|
-
return ResponseRedirect(self.request.data.get("redirect_url", "."))
|
@@ -1,144 +0,0 @@
|
|
1
|
-
<!DOCTYPE html>
|
2
|
-
<html lang="en">
|
3
|
-
<head>
|
4
|
-
<meta charset="UTF-8">
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6
|
-
<title>Querystats</title>
|
7
|
-
{% tailwind_css %}
|
8
|
-
</head>
|
9
|
-
<body class="text-stone-300">
|
10
|
-
|
11
|
-
{% if querystats_enabled %}
|
12
|
-
<div class="flex items-center justify-between border-b border-white/5 px-6 h-14 fixed top-0 left-0 right-0 bg-stone-950 z-10">
|
13
|
-
<!-- <h1 class="text-lg font-semibold">Querystats</h1> -->
|
14
|
-
<div></div>
|
15
|
-
<div class="flex items-center space-x-2">
|
16
|
-
<form method="get" action=".">
|
17
|
-
{{ csrf_input }}
|
18
|
-
<button type="submit" class="px-2 py-px text-sm rounded-sm bg-stone-700 text-stone-300 hover:bg-stone-600 cursor-pointer whitespace-nowrap">Reload</button>
|
19
|
-
</form>
|
20
|
-
<form method="post" action=".">
|
21
|
-
{{ csrf_input }}
|
22
|
-
<input type="hidden" name="querystats_action" value="clear">
|
23
|
-
<button type="submit" class="px-2 py-px text-sm rounded-sm bg-stone-700 text-stone-300 hover:bg-stone-600 cursor-pointer whitespace-nowrap">Clear</button>
|
24
|
-
</form>
|
25
|
-
<form method="post" action=".">
|
26
|
-
{{ csrf_input }}
|
27
|
-
<input type="hidden" name="querystats_action" value="disable">
|
28
|
-
<button type="submit" class="px-2 py-px text-sm rounded-sm bg-stone-700 text-stone-300 hover:bg-stone-600 cursor-pointer whitespace-nowrap">Disable</button>
|
29
|
-
</form>
|
30
|
-
</div>
|
31
|
-
</div>
|
32
|
-
{% endif %}
|
33
|
-
|
34
|
-
{% if querystats %}
|
35
|
-
<div class="flex mt-2 h-full">
|
36
|
-
<aside id="sidebar" class="fixed left-0 top-14 bottom-0 w-82 overflow-auto p-4">
|
37
|
-
<ul class="space-y-2">
|
38
|
-
{% for request_id, qs in querystats.items() %}
|
39
|
-
<li>
|
40
|
-
<button data-request-id="{{ request_id }}" class="w-full text-left px-2 py-1 rounded hover:bg-stone-700 cursor-pointer">
|
41
|
-
<span class="text-sm">{{ qs.request.path }}</span>
|
42
|
-
<span class="font-semibold bg-white/5 rounded-sm px-1 py-0.5 text-xs">{{ qs.request.method }}</span>
|
43
|
-
<div class="text-xs text-stone-400">{{ qs.summary }}</div>
|
44
|
-
<div class="text-xs text-stone-500">{{ qs.timestamp|fromisoformat|timesince }} ago</div>
|
45
|
-
</button>
|
46
|
-
</li>
|
47
|
-
{% endfor %}
|
48
|
-
</ul>
|
49
|
-
</aside>
|
50
|
-
|
51
|
-
<main id="content" class="flex-1 p-6 overflow-auto ml-82 mt-14">
|
52
|
-
{% for request_id, qs in querystats.items() %}
|
53
|
-
<div class="request-detail" data-request-id="{{ request_id }}" style="display: none;">
|
54
|
-
<div class="flex justify-between">
|
55
|
-
<div>
|
56
|
-
<h2 class="font-medium text-sm"><span class="font-semibold">{{ qs.request.method }}</span> {{ qs.request.path }}</h2>
|
57
|
-
<p class="text-sm text-white/70">{{ qs.summary }}</p>
|
58
|
-
</div>
|
59
|
-
<div class="text-right">
|
60
|
-
<div class="text-xs text-white/60">Request ID <code>{{ qs.request.unique_id }}</code></div>
|
61
|
-
<div class="text-xs text-white/60"><code>{{ qs.timestamp|fromisoformat }}</code></div>
|
62
|
-
</div>
|
63
|
-
</div>
|
64
|
-
|
65
|
-
<div class="flex w-full mt-3 overflow-auto rounded-sm">
|
66
|
-
{% for query in qs.queries %}
|
67
|
-
<a href="#query-{{ loop.index }}"
|
68
|
-
{{ loop.cycle('class=\"h-2 bg-amber-400\"', 'class=\"h-2 bg-orange-400\"', 'class=\"h-2 bg-yellow-400\"', 'class=\"h-2 bg-amber-600\"')|safe }}
|
69
|
-
title="[{{ query.duration_display }}] {{ query.sql_display }}"
|
70
|
-
style="width: {{ query.duration / qs.total_time * 100 }}%">
|
71
|
-
</a>
|
72
|
-
{% endfor %}
|
73
|
-
</div>
|
74
|
-
|
75
|
-
<div class="mt-4 space-y-3 text-xs">
|
76
|
-
{% for query in qs.queries %}
|
77
|
-
<details id="query-{{ loop.index }}" class="p-2 rounded bg-white/5">
|
78
|
-
<summary class="truncate">
|
79
|
-
<div class="float-right px-2 py-px mb-px ml-2 text-xs rounded-full bg-zinc-700">
|
80
|
-
<span>{{ query.duration_display }}</span>
|
81
|
-
{% if query.duplicate_count is defined %}
|
82
|
-
<span class="text-red-500"> duplicated {{ query.duplicate_count }} times</span>
|
83
|
-
{% endif %}
|
84
|
-
</div>
|
85
|
-
<code class="font-mono">{{ query.sql }}</code>
|
86
|
-
</summary>
|
87
|
-
<div class="space-y-3 mt-3">
|
88
|
-
<div>
|
89
|
-
<pre><code class="font-mono whitespace-pre-wrap text-zinc-100">{{ query.sql_display }}</code></pre>
|
90
|
-
</div>
|
91
|
-
<div class="text-zinc-400">
|
92
|
-
<span class="font-medium">Parameters</span>
|
93
|
-
<pre><code class="font-mono">{{ query.params|pprint }}</code></pre>
|
94
|
-
</div>
|
95
|
-
{% if query.tb|default(false) %}
|
96
|
-
<details>
|
97
|
-
<summary>Traceback</summary>
|
98
|
-
<pre><code class="block overflow-x-auto font-mono text-xs">{{ query.tb }}</code></pre>
|
99
|
-
</details>
|
100
|
-
{% endif %}
|
101
|
-
</div>
|
102
|
-
</details>
|
103
|
-
{% else %}
|
104
|
-
<div>No queries...</div>
|
105
|
-
{% endfor %}
|
106
|
-
</div>
|
107
|
-
</div>
|
108
|
-
{% endfor %}
|
109
|
-
</main>
|
110
|
-
</div>
|
111
|
-
{% elif querystats_enabled %}
|
112
|
-
<div class="text-center text-white/30 py-8">Querystats are enabled but nothing has been recorded yet.</div>
|
113
|
-
{% else %}
|
114
|
-
<div class="text-center py-8">
|
115
|
-
<div class="text-white/30">Querystats are disabled.</div>
|
116
|
-
<form method="post" action=".">
|
117
|
-
{{ csrf_input }}
|
118
|
-
<input type="hidden" name="querystats_action" value="enable">
|
119
|
-
<button type="submit" class="mt-2 px-2 py-px text-sm rounded-sm bg-stone-700 text-stone-300 hover:bg-stone-600 cursor-pointer whitespace-nowrap">Enable</button>
|
120
|
-
</form>
|
121
|
-
</div>
|
122
|
-
{% endif %}
|
123
|
-
|
124
|
-
<script>
|
125
|
-
document.addEventListener('DOMContentLoaded', function() {
|
126
|
-
const buttons = document.querySelectorAll('#sidebar [data-request-id]');
|
127
|
-
const details = document.querySelectorAll('#content .request-detail');
|
128
|
-
buttons.forEach(function(btn) {
|
129
|
-
btn.addEventListener('click', function(e) {
|
130
|
-
e.preventDefault();
|
131
|
-
const id = this.getAttribute('data-request-id');
|
132
|
-
details.forEach(div => div.style.display = 'none');
|
133
|
-
const sel = document.querySelector('#content .request-detail[data-request-id="' + id + '"]');
|
134
|
-
if (sel) sel.style.display = 'block';
|
135
|
-
buttons.forEach(b => b.classList.remove('bg-stone-700', 'text-white'));
|
136
|
-
this.classList.add('bg-stone-700', 'text-white');
|
137
|
-
});
|
138
|
-
});
|
139
|
-
if (buttons.length > 0) buttons[0].click();
|
140
|
-
});
|
141
|
-
</script>
|
142
|
-
|
143
|
-
</body>
|
144
|
-
</html>
|
@@ -1,90 +0,0 @@
|
|
1
|
-
<div data-querystats class="relative group/querystats" style="display: none;">
|
2
|
-
{% if "querystats" in request.session %}
|
3
|
-
<button data-toolbar-tab="querystats" class="inline-flex items-center cursor-pointer text-xs rounded-full px-2 py-px bg-white/20 text-white/80 whitespace-nowrap">
|
4
|
-
<span class="relative inline-flex size-2 mr-2">
|
5
|
-
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
6
|
-
<span class="relative inline-flex size-2 rounded-full bg-green-500"></span>
|
7
|
-
</span>
|
8
|
-
<span data-querystats-summary></span>
|
9
|
-
</button>
|
10
|
-
{% else %}
|
11
|
-
<form action="{{ url('admin:querystats:querystats') }}" method="post">
|
12
|
-
{{ csrf_input }}
|
13
|
-
<input type="hidden" name="redirect_url" value="{{ request.get_full_path() }}">
|
14
|
-
<input type="hidden" name="querystats_action" value="enable">
|
15
|
-
<button type="submit" class="cursor-pointer text-xs rounded-full px-2 py-px bg-white/20 text-white/80 whitespace-nowrap">
|
16
|
-
<span class="rounded-full bg-zinc-500 w-2 h-2 inline-block mr-1"></span>
|
17
|
-
<span data-querystats-summary></span>
|
18
|
-
</button>
|
19
|
-
</form>
|
20
|
-
{% endif %}
|
21
|
-
|
22
|
-
<div data-querystats-list style="display: none;" class="absolute z-50 hidden -translate-y-full right-0 -top-1 group-hover/querystats:block">
|
23
|
-
<div class="p-2 text-xs border rounded shadow-md bg-zinc-900 border-zinc-700"><table><tbody></tbody></table></div>
|
24
|
-
</div>
|
25
|
-
<script async defer>
|
26
|
-
// Catch errors since some browsers throw when using the new `type` option.
|
27
|
-
// https://bugs.webkit.org/show_bug.cgi?id=209216
|
28
|
-
var querystatsTimings = [];
|
29
|
-
function renderQuerystats() {
|
30
|
-
// Render the original timing call
|
31
|
-
let summary = querystatsTimings[0].description;
|
32
|
-
if (querystatsTimings.length > 1) {
|
33
|
-
summary += ` *`;
|
34
|
-
}
|
35
|
-
document.querySelector('[data-querystats-summary]').innerText = summary;
|
36
|
-
|
37
|
-
// Make sure the elements are visible
|
38
|
-
document.querySelector('[data-querystats]').style.display = 'inline';
|
39
|
-
|
40
|
-
// Render the table rows for all timings
|
41
|
-
const list = document.querySelector('[data-querystats-list]');
|
42
|
-
if (querystatsTimings.length > 1) {
|
43
|
-
const tableRows = querystatsTimings.map(timing => {
|
44
|
-
let url = timing.url;
|
45
|
-
if (url.startsWith(window.location.origin)) {
|
46
|
-
// Make the url relative if possible (usually is)
|
47
|
-
url = url.slice(window.location.origin.length);
|
48
|
-
}
|
49
|
-
return `<tr>
|
50
|
-
<td class="pr-2 font-medium whitespace-nowrap">${url}</td>
|
51
|
-
<td class="whitespace-nowrap">${timing.description}</td>
|
52
|
-
</tr>`;
|
53
|
-
}).join('');
|
54
|
-
list.querySelector("tbody").innerHTML = tableRows;
|
55
|
-
list.style.display = '';
|
56
|
-
} else {
|
57
|
-
list.style.display = 'none';
|
58
|
-
}
|
59
|
-
}
|
60
|
-
try {
|
61
|
-
const po = new PerformanceObserver((list) => {
|
62
|
-
for (const entry of list.getEntries()) {
|
63
|
-
if (!entry.serverTiming) {
|
64
|
-
console.warn("Server timing not available for querystats.")
|
65
|
-
return;
|
66
|
-
}
|
67
|
-
for (const timing of entry.serverTiming) {
|
68
|
-
if (timing.name === "querystats") {
|
69
|
-
// Log a helpful summary instead of the entire entry object
|
70
|
-
console.log(`[Querystats] ${timing.description} on ${entry.name}`, entry)
|
71
|
-
timing.url = entry.name; // Store this for reference later
|
72
|
-
for (const existingTiming of querystatsTimings) {
|
73
|
-
if (existingTiming == timing) {
|
74
|
-
// Skip duplicate timings (happens on initial load...)
|
75
|
-
return;
|
76
|
-
}
|
77
|
-
}
|
78
|
-
querystatsTimings.push(timing);
|
79
|
-
renderQuerystats();
|
80
|
-
}
|
81
|
-
}
|
82
|
-
}
|
83
|
-
});
|
84
|
-
po.observe({type: 'navigation', buffered: true}); // Catch the regular page loads
|
85
|
-
po.observe({type: 'resource', buffered: true}); // Catch future ajax requests
|
86
|
-
} catch (e) {
|
87
|
-
// Do nothing if the browser doesn't support this API.
|
88
|
-
}
|
89
|
-
</script>
|
90
|
-
</div>
|
@@ -1,28 +0,0 @@
|
|
1
|
-
<div id="querystats-container" class="h-full">
|
2
|
-
<div class="px-6 py-4 text-center">
|
3
|
-
<p>Loading querystats...</p>
|
4
|
-
</div>
|
5
|
-
</div>
|
6
|
-
<script>
|
7
|
-
(function() {
|
8
|
-
var container = document.getElementById('querystats-container');
|
9
|
-
var loaded = false;
|
10
|
-
var parent = container.parentNode;
|
11
|
-
var observer = new IntersectionObserver(function(entries) {
|
12
|
-
entries.forEach(function(entry) {
|
13
|
-
if (entry.isIntersecting && !loaded) {
|
14
|
-
loaded = true;
|
15
|
-
var iframe = document.createElement('iframe');
|
16
|
-
iframe.src = "{{ url('admin:querystats:querystats') }}";
|
17
|
-
iframe.frameBorder = "0";
|
18
|
-
iframe.style.width = "100%";
|
19
|
-
iframe.style.height = "100%";
|
20
|
-
container.innerHTML = '';
|
21
|
-
container.appendChild(iframe);
|
22
|
-
observer.disconnect();
|
23
|
-
}
|
24
|
-
});
|
25
|
-
}, { root: parent, threshold: 0 });
|
26
|
-
observer.observe(container);
|
27
|
-
})();
|
28
|
-
</script>
|
File without changes
|
File without changes
|