plain.admin 0.27.0__tar.gz → 0.28.0__tar.gz

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.
Files changed (92) hide show
  1. {plain_admin-0.27.0 → plain_admin-0.28.0}/PKG-INFO +1 -1
  2. plain_admin-0.28.0/plain/admin/assets/toolbar/toolbar.js +162 -0
  3. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/querystats/core.py +0 -1
  4. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/querystats/middleware.py +7 -8
  5. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/querystats/views.py +34 -3
  6. plain_admin-0.28.0/plain/admin/templates/querystats/querystats.html +142 -0
  7. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/querystats/toolbar.html +4 -4
  8. plain_admin-0.28.0/plain/admin/templates/toolbar/toolbar.html +245 -0
  9. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/toolbar.py +1 -3
  10. {plain_admin-0.27.0 → plain_admin-0.28.0}/pyproject.toml +1 -1
  11. plain_admin-0.27.0/plain/admin/assets/toolbar/toolbar.js +0 -51
  12. plain_admin-0.27.0/plain/admin/templates/querystats/querystats.html +0 -110
  13. plain_admin-0.27.0/plain/admin/templates/toolbar/toolbar.html +0 -86
  14. {plain_admin-0.27.0 → plain_admin-0.28.0}/.gitignore +0 -0
  15. {plain_admin-0.27.0 → plain_admin-0.28.0}/LICENSE +0 -0
  16. {plain_admin-0.27.0 → plain_admin-0.28.0}/README.md +0 -0
  17. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/README.md +0 -0
  18. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/__init__.py +0 -0
  19. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/assets/admin/admin.css +0 -0
  20. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/assets/admin/admin.js +0 -0
  21. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/assets/admin/chart.js +0 -0
  22. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/assets/admin/jquery-3.6.1.slim.min.js +0 -0
  23. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/assets/admin/list.js +0 -0
  24. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/assets/admin/popper.min.js +0 -0
  25. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/assets/admin/tippy-bundle.umd.min.js +0 -0
  26. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/cards/__init__.py +0 -0
  27. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/cards/base.py +0 -0
  28. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/cards/charts.py +0 -0
  29. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/cards/tables.py +0 -0
  30. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/config.py +0 -0
  31. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/dates.py +0 -0
  32. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/default_settings.py +0 -0
  33. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/impersonate/README.md +0 -0
  34. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/impersonate/__init__.py +0 -0
  35. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/impersonate/middleware.py +0 -0
  36. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/impersonate/models.py +0 -0
  37. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/impersonate/permissions.py +0 -0
  38. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/impersonate/settings.py +0 -0
  39. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/impersonate/urls.py +0 -0
  40. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/impersonate/views.py +0 -0
  41. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/middleware.py +0 -0
  42. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/querystats/README.md +0 -0
  43. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/querystats/__init__.py +0 -0
  44. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/querystats/urls.py +0 -0
  45. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/base.html +0 -0
  46. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/cards/base.html +0 -0
  47. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/cards/card.html +0 -0
  48. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/cards/chart.html +0 -0
  49. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/cards/table.html +0 -0
  50. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/delete.html +0 -0
  51. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/detail.html +0 -0
  52. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/index.html +0 -0
  53. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/list.html +0 -0
  54. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/page.html +0 -0
  55. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/search.html +0 -0
  56. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/UUID.html +0 -0
  57. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/bool.html +0 -0
  58. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/datetime.html +0 -0
  59. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/default.html +0 -0
  60. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/dict.html +0 -0
  61. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/get_display.html +0 -0
  62. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/img.html +0 -0
  63. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/list.html +0 -0
  64. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/model.html +0 -0
  65. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/admin/values/queryset.html +0 -0
  66. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/Checkbox.html +0 -0
  67. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/CheckboxField.html +0 -0
  68. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/FieldErrors.html +0 -0
  69. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/Help.html +0 -0
  70. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/Input.html +0 -0
  71. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/InputField.html +0 -0
  72. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/Label.html +0 -0
  73. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/Select.html +0 -0
  74. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/SelectField.html +0 -0
  75. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/Submit.html +0 -0
  76. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/Textarea.html +0 -0
  77. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates/elements/admin/TextareaField.html +0 -0
  78. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/templates.py +0 -0
  79. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/urls.py +0 -0
  80. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/views/__init__.py +0 -0
  81. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/views/base.py +0 -0
  82. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/views/models.py +0 -0
  83. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/views/objects.py +0 -0
  84. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/views/registry.py +0 -0
  85. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/views/types.py +0 -0
  86. {plain_admin-0.27.0 → plain_admin-0.28.0}/plain/admin/views/viewsets.py +0 -0
  87. {plain_admin-0.27.0 → plain_admin-0.28.0}/tests/app/settings.py +0 -0
  88. {plain_admin-0.27.0 → plain_admin-0.28.0}/tests/app/urls.py +0 -0
  89. {plain_admin-0.27.0 → plain_admin-0.28.0}/tests/app/users/migrations/0001_initial.py +0 -0
  90. {plain_admin-0.27.0 → plain_admin-0.28.0}/tests/app/users/migrations/__init__.py +0 -0
  91. {plain_admin-0.27.0 → plain_admin-0.28.0}/tests/app/users/models.py +0 -0
  92. {plain_admin-0.27.0 → plain_admin-0.28.0}/tests/test_admin.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.admin
3
- Version: 0.27.0
3
+ Version: 0.28.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
@@ -0,0 +1,162 @@
1
+ // Make this available to the JS console for the user
2
+ var plainToolbar = {
3
+ hide: function () {
4
+ // Hide by inserting a style so it doesn't flash on page load
5
+ var style = document.createElement("style");
6
+ style.innerHTML = "#plaintoolbar { display: none; }";
7
+ document.getElementsByTagName("head")[0].appendChild(style);
8
+ this.stylesheet = style;
9
+ },
10
+ show: function () {
11
+ localStorage.removeItem("plaintoolbar.hidden_until");
12
+ if (this.stylesheet) {
13
+ this.stylesheet.remove();
14
+ }
15
+ },
16
+ shouldHide: function () {
17
+ var hiddenUntil = localStorage.getItem("plaintoolbar.hidden_until");
18
+ if (hiddenUntil) {
19
+ if (Date.now() < hiddenUntil) {
20
+ return true;
21
+ } else {
22
+ localStorage.removeItem("plaintoolbar.hidden_until");
23
+ return false;
24
+ }
25
+ }
26
+ return false;
27
+ },
28
+ hideUntil: function (until) {
29
+ localStorage.setItem("plaintoolbar.hidden_until", until);
30
+ this.hide();
31
+ },
32
+ toggleExpand: function () {
33
+ this.expanded = !this.expanded;
34
+ document.querySelector("#plaintoolbar-details").classList.toggle("hidden");
35
+ localStorage.setItem('plaintoolbar.expanded', this.expanded ? '1' : '0');
36
+ },
37
+ expand: function () {
38
+ this.expanded = true;
39
+ document.querySelector("#plaintoolbar-details").classList.remove("hidden");
40
+ localStorage.setItem('plaintoolbar.expanded', '1');
41
+ },
42
+ collapse: function () {
43
+ this.expanded = false;
44
+ document.querySelector("#plaintoolbar-details").classList.add("hidden");
45
+ localStorage.setItem('plaintoolbar.expanded', '0');
46
+ },
47
+ showTab: function (tabName) {
48
+ this.expand();
49
+
50
+ var toolbar = document.querySelector("#plaintoolbar");
51
+ var tab = toolbar.querySelector("div[data-toolbar-tab=" + tabName + "]");
52
+
53
+ // Hide all children in the tab parent
54
+ for (var i = 0; i < tab.parentNode.children.length; i++) {
55
+ var child = tab.parentNode.children[i];
56
+ if (child !== tab) {
57
+ child.style.display = "none";
58
+ }
59
+ }
60
+
61
+ tab.style.display = "block";
62
+
63
+ toolbar.querySelectorAll("button[data-toolbar-tab]").forEach(function (tab) {
64
+ if (tab.dataset.toolbarTab === tabName) {
65
+ tab.setAttribute("data-active", true);
66
+ } else {
67
+ tab.removeAttribute("data-active");
68
+ }
69
+ });
70
+ localStorage.setItem('plaintoolbar.tab', tabName);
71
+ },
72
+ };
73
+
74
+ // Render it hidden immediately if the user has hidden it before
75
+ if (plainToolbar.shouldHide()) {
76
+ plainToolbar.hide();
77
+ }
78
+
79
+ window.addEventListener("load", function() {
80
+ // Restore expanded/collapsed state
81
+ var state = localStorage.getItem('plaintoolbar.expanded');
82
+ if (state === '1') {
83
+ plainToolbar.expand();
84
+ // Restore last active tab
85
+ var lastTab = localStorage.getItem('plaintoolbar.tab');
86
+ if (lastTab) {
87
+ plainToolbar.showTab(lastTab);
88
+ }
89
+ } else if (state === '0') {
90
+ plainToolbar.collapse();
91
+ }
92
+ var toolbar = document.querySelector("#plaintoolbar");
93
+
94
+ toolbar.querySelectorAll("button[data-toolbar-tab]").forEach(function (tab) {
95
+ tab.addEventListener("click", function () {
96
+ plainToolbar.showTab(tab.dataset.toolbarTab);
97
+ });
98
+ });
99
+
100
+ toolbar.querySelectorAll('[data-plaintoolbar-hide]').forEach(function(btn) {
101
+ btn.addEventListener('click', function() {
102
+ plainToolbar.hide();
103
+ });
104
+ });
105
+
106
+ toolbar.querySelectorAll('[data-plaintoolbar-hideuntil]').forEach(function(btn) {
107
+ btn.addEventListener('click', function() {
108
+ console.log("Hiding admin toolbar for 1 hour");
109
+ plainToolbar.hideUntil(Date.now() + 3600000);
110
+ });
111
+ });
112
+
113
+ toolbar.querySelectorAll('[data-plaintoolbar-expand]').forEach(function(btn) {
114
+ btn.addEventListener('click', function() {
115
+ plainToolbar.toggleExpand();
116
+ });
117
+ });
118
+
119
+ // Enable manual resize of the expanded toolbar via drag handle
120
+ var details = document.getElementById('plaintoolbar-details');
121
+ if (details) {
122
+ var handle = details.querySelector('[data-resizer]');
123
+ var content = handle.nextElementSibling;
124
+ var isDragging = false;
125
+ var startY = 0;
126
+ var startHeight = 0;
127
+ if (handle && content) {
128
+ // Initial cursor
129
+ handle.style.cursor = 'grab';
130
+ // Start dragging
131
+ handle.addEventListener('mousedown', function(e) {
132
+ isDragging = true;
133
+ startY = e.clientY;
134
+ startHeight = content.offsetHeight;
135
+ handle.style.cursor = 'grabbing';
136
+ // Prevent text selection while dragging
137
+ document.body.style.userSelect = 'none';
138
+ e.preventDefault();
139
+ });
140
+ // Handle dragging
141
+ document.addEventListener('mousemove', function(e) {
142
+ if (!isDragging) return;
143
+ var delta = e.clientY - startY;
144
+ // Calculate new height: dragging up increases height
145
+ var newHeight = startHeight - delta;
146
+ // Clamp between reasonable bounds
147
+ var minHeight = 50;
148
+ var maxHeight = window.innerHeight - 100;
149
+ newHeight = Math.max(minHeight, Math.min(maxHeight, newHeight));
150
+ content.style.height = newHeight + 'px';
151
+ });
152
+ // End dragging
153
+ document.addEventListener('mouseup', function() {
154
+ if (isDragging) {
155
+ isDragging = false;
156
+ handle.style.cursor = 'grab';
157
+ document.body.style.userSelect = '';
158
+ }
159
+ });
160
+ }
161
+ }
162
+ });
@@ -141,7 +141,6 @@ class QueryStats:
141
141
  "request": {
142
142
  "path": request.path,
143
143
  "method": request.method,
144
- "headers": dict(request.headers),
145
144
  "unique_id": request.unique_id,
146
145
  },
147
146
  "timestamp": time.time(),
@@ -55,15 +55,14 @@ class QueryStatsMiddleware:
55
55
  if self.should_ignore_request(request):
56
56
  return self.get_response(request)
57
57
 
58
- session_querystats_enabled = "querystats" in request.session
59
-
60
- querystats = QueryStats(include_tracebacks=session_querystats_enabled)
61
- with connection.execute_wrapper(querystats):
62
- # Have to wrap this first call so it is included in the querystats,
63
- # but we don't have to wrap everything else unless we are admin or debug
64
- is_admin = self.is_admin_request(request)
58
+ # Previously we wrapped this in execute_wrapper too,
59
+ # but now there's a chicken and the egg issue with "querystats" being by the view,
60
+ # which hasn't been processed yet (on the initial request)
61
+ is_admin = self.is_admin_request(request)
65
62
 
66
63
  if settings.DEBUG or is_admin:
64
+ tracking = "querystats" in request.session
65
+ querystats = QueryStats(include_tracebacks=tracking)
67
66
  with connection.execute_wrapper(querystats):
68
67
  response = self.get_response(request)
69
68
 
@@ -75,7 +74,7 @@ class QueryStatsMiddleware:
75
74
  # by using the server timing API which can be parsed client-side
76
75
  response.headers["Server-Timing"] = querystats.as_server_timing()
77
76
 
78
- if session_querystats_enabled and querystats.num_queries > 0:
77
+ if tracking and querystats.num_queries > 0:
79
78
  request.session["querystats"][request.unique_id] = json.dumps(
80
79
  querystats.as_context_dict(request), cls=QueryStatsJSONEncoder
81
80
  )
@@ -1,7 +1,9 @@
1
+ import datetime
1
2
  import json
2
3
 
3
4
  from plain.auth.views import AuthViewMixin
4
5
  from plain.http import ResponseRedirect
6
+ from plain.runtime import settings
5
7
  from plain.views import TemplateView
6
8
 
7
9
 
@@ -9,15 +11,39 @@ class QuerystatsView(AuthViewMixin, TemplateView):
9
11
  template_name = "querystats/querystats.html"
10
12
  admin_required = True
11
13
 
14
+ def check_auth(self):
15
+ # Allow the view if we're in DEBUG
16
+ if settings.DEBUG:
17
+ return
18
+
19
+ super().check_auth()
20
+
21
+ def get_response(self):
22
+ response = super().get_response()
23
+ # So we can load it in the toolbar
24
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
25
+ return response
26
+
27
+ def get(self):
28
+ # Give an easy out if things get messed up
29
+ if (
30
+ "clear" in self.request.query_params
31
+ and "querystats" in self.request.session
32
+ ):
33
+ del self.request.session["querystats"]
34
+ self.request.session.modified = True
35
+
36
+ return super().get()
37
+
12
38
  def get_template_context(self):
13
39
  context = super().get_template_context()
14
40
 
15
41
  querystats = self.request.session.get("querystats", {})
16
42
 
17
- for request_id, json_data in querystats.items():
43
+ for request_id in list(querystats.keys()):
18
44
  try:
19
- querystats[request_id] = json.loads(json_data)
20
- except json.JSONDecodeError:
45
+ querystats[request_id] = json.loads(querystats[request_id])
46
+ except (json.JSONDecodeError, TypeError):
21
47
  # If decoding fails, remove the entry from the dictionary
22
48
  del querystats[request_id]
23
49
 
@@ -30,7 +56,12 @@ class QuerystatsView(AuthViewMixin, TemplateView):
30
56
  )
31
57
  )
32
58
 
59
+ # Convert the timestamps back to a python datetime object
60
+ for data in querystats.values():
61
+ data["timestamp"] = datetime.datetime.fromtimestamp(data["timestamp"])
62
+
33
63
  context["querystats"] = querystats
64
+ context["querystats_enabled"] = "querystats" in self.request.session
34
65
 
35
66
  return context
36
67
 
@@ -0,0 +1,142 @@
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|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 }}</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">&nbsp; 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
+ <details>
96
+ <summary>Traceback</summary>
97
+ <pre><code class="block overflow-x-auto font-mono text-xs">{{ query.tb }}</code></pre>
98
+ </details>
99
+ </div>
100
+ </details>
101
+ {% else %}
102
+ <div>No queries...</div>
103
+ {% endfor %}
104
+ </div>
105
+ </div>
106
+ {% endfor %}
107
+ </main>
108
+ </div>
109
+ {% elif querystats_enabled %}
110
+ <div class="text-center text-white/30 py-8">Querystats are enabled but nothing has been recorded yet.</div>
111
+ {% else %}
112
+ <div class="text-center py-8">
113
+ <div class="text-white/30">Querystats are disabled.</div>
114
+ <form method="post" action=".">
115
+ {{ csrf_input }}
116
+ <input type="hidden" name="querystats_action" value="enable">
117
+ <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>
118
+ </form>
119
+ </div>
120
+ {% endif %}
121
+
122
+ <script>
123
+ document.addEventListener('DOMContentLoaded', function() {
124
+ const buttons = document.querySelectorAll('#sidebar [data-request-id]');
125
+ const details = document.querySelectorAll('#content .request-detail');
126
+ buttons.forEach(function(btn) {
127
+ btn.addEventListener('click', function(e) {
128
+ e.preventDefault();
129
+ const id = this.getAttribute('data-request-id');
130
+ details.forEach(div => div.style.display = 'none');
131
+ const sel = document.querySelector('#content .request-detail[data-request-id="' + id + '"]');
132
+ if (sel) sel.style.display = 'block';
133
+ buttons.forEach(b => b.classList.remove('bg-stone-700', 'text-white'));
134
+ this.classList.add('bg-stone-700', 'text-white');
135
+ });
136
+ });
137
+ if (buttons.length > 0) buttons[0].click();
138
+ });
139
+ </script>
140
+
141
+ </body>
142
+ </html>
@@ -1,25 +1,25 @@
1
1
  <div data-querystats class="relative group/querystats" style="display: none;">
2
2
  {% if "querystats" in request.session %}
3
- <a href="{{ url('admin:querystats:querystats') }}" target="querystats" class="inline-flex items-center cursor-pointer text-xs rounded-full px-2 py-px bg-stone-700 text-stone-300 whitespace-nowrap">
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
4
  <span class="relative inline-flex size-2 mr-2">
5
5
  <span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
6
6
  <span class="relative inline-flex size-2 rounded-full bg-green-500"></span>
7
7
  </span>
8
8
  <span data-querystats-summary></span>
9
- </a>
9
+ </button>
10
10
  {% else %}
11
11
  <form action="{{ url('admin:querystats:querystats') }}" method="post">
12
12
  {{ csrf_input }}
13
13
  <input type="hidden" name="redirect_url" value="{{ request.get_full_path() }}">
14
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-stone-700 text-stone-300 whitespace-nowrap">
15
+ <button type="submit" class="cursor-pointer text-xs rounded-full px-2 py-px bg-white/20 text-white/80 whitespace-nowrap">
16
16
  <span class="rounded-full bg-zinc-500 w-2 h-2 inline-block mr-1"></span>
17
17
  <span data-querystats-summary></span>
18
18
  </button>
19
19
  </form>
20
20
  {% endif %}
21
21
 
22
- <div data-querystats-list style="display: none;" class="absolute z-50 -translate-x-1/2 hidden -translate-y-full left-1/2 -top-1 group-hover/querystats:block">
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
23
  <div class="p-2 text-xs border rounded shadow-md bg-zinc-900 border-zinc-700"><table><tbody></tbody></table></div>
24
24
  </div>
25
25
  <script async defer>