plain.admin 0.25.1__tar.gz → 0.27.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 (93) hide show
  1. {plain_admin-0.25.1 → plain_admin-0.27.0}/PKG-INFO +1 -1
  2. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/assets/admin/admin.css +6 -0
  3. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/assets/admin/admin.js +1 -2
  4. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/cards/base.py +1 -1
  5. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/cards/charts.py +2 -4
  6. plain_admin-0.27.0/plain/admin/default_settings.py +4 -0
  7. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/impersonate/views.py +2 -2
  8. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/querystats/core.py +14 -10
  9. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/querystats/middleware.py +32 -28
  10. plain_admin-0.27.0/plain/admin/querystats/views.py +48 -0
  11. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/base.html +5 -5
  12. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/cards/base.html +2 -2
  13. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/cards/card.html +1 -1
  14. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/list.html +6 -6
  15. plain_admin-0.27.0/plain/admin/templates/elements/admin/Help.html +1 -0
  16. plain_admin-0.27.0/plain/admin/templates/querystats/querystats.html +110 -0
  17. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/querystats/toolbar.html +31 -21
  18. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates.py +3 -3
  19. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/toolbar.py +1 -1
  20. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/urls.py +1 -1
  21. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/views/models.py +3 -3
  22. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/views/objects.py +6 -6
  23. {plain_admin-0.25.1 → plain_admin-0.27.0}/pyproject.toml +1 -1
  24. plain_admin-0.25.1/plain/admin/default_settings.py +0 -4
  25. plain_admin-0.25.1/plain/admin/querystats/views.py +0 -27
  26. plain_admin-0.25.1/plain/admin/templates/elements/admin/Help.html +0 -1
  27. plain_admin-0.25.1/plain/admin/templates/querystats/querystats.html +0 -78
  28. {plain_admin-0.25.1 → plain_admin-0.27.0}/.gitignore +0 -0
  29. {plain_admin-0.25.1 → plain_admin-0.27.0}/LICENSE +0 -0
  30. {plain_admin-0.25.1 → plain_admin-0.27.0}/README.md +0 -0
  31. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/README.md +0 -0
  32. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/__init__.py +0 -0
  33. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/assets/admin/chart.js +0 -0
  34. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/assets/admin/jquery-3.6.1.slim.min.js +0 -0
  35. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/assets/admin/list.js +0 -0
  36. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/assets/admin/popper.min.js +0 -0
  37. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/assets/admin/tippy-bundle.umd.min.js +0 -0
  38. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/assets/toolbar/toolbar.js +0 -0
  39. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/cards/__init__.py +0 -0
  40. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/cards/tables.py +0 -0
  41. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/config.py +0 -0
  42. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/dates.py +0 -0
  43. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/impersonate/README.md +0 -0
  44. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/impersonate/__init__.py +0 -0
  45. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/impersonate/middleware.py +0 -0
  46. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/impersonate/models.py +0 -0
  47. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/impersonate/permissions.py +0 -0
  48. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/impersonate/settings.py +0 -0
  49. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/impersonate/urls.py +0 -0
  50. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/middleware.py +0 -0
  51. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/querystats/README.md +0 -0
  52. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/querystats/__init__.py +0 -0
  53. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/querystats/urls.py +0 -0
  54. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/cards/chart.html +0 -0
  55. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/cards/table.html +0 -0
  56. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/delete.html +0 -0
  57. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/detail.html +0 -0
  58. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/index.html +0 -0
  59. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/page.html +0 -0
  60. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/search.html +0 -0
  61. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/UUID.html +0 -0
  62. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/bool.html +0 -0
  63. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/datetime.html +0 -0
  64. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/default.html +0 -0
  65. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/dict.html +0 -0
  66. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/get_display.html +0 -0
  67. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/img.html +0 -0
  68. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/list.html +0 -0
  69. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/model.html +0 -0
  70. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/admin/values/queryset.html +0 -0
  71. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/Checkbox.html +0 -0
  72. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/CheckboxField.html +0 -0
  73. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/FieldErrors.html +0 -0
  74. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/Input.html +0 -0
  75. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/InputField.html +0 -0
  76. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/Label.html +0 -0
  77. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/Select.html +0 -0
  78. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/SelectField.html +0 -0
  79. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/Submit.html +0 -0
  80. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/Textarea.html +0 -0
  81. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/elements/admin/TextareaField.html +0 -0
  82. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/templates/toolbar/toolbar.html +0 -0
  83. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/views/__init__.py +0 -0
  84. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/views/base.py +0 -0
  85. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/views/registry.py +0 -0
  86. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/views/types.py +0 -0
  87. {plain_admin-0.25.1 → plain_admin-0.27.0}/plain/admin/views/viewsets.py +0 -0
  88. {plain_admin-0.25.1 → plain_admin-0.27.0}/tests/app/settings.py +0 -0
  89. {plain_admin-0.25.1 → plain_admin-0.27.0}/tests/app/urls.py +0 -0
  90. {plain_admin-0.25.1 → plain_admin-0.27.0}/tests/app/users/migrations/0001_initial.py +0 -0
  91. {plain_admin-0.25.1 → plain_admin-0.27.0}/tests/app/users/migrations/__init__.py +0 -0
  92. {plain_admin-0.25.1 → plain_admin-0.27.0}/tests/app/users/models.py +0 -0
  93. {plain_admin-0.25.1 → plain_admin-0.27.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.25.1
3
+ Version: 0.27.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
@@ -55,6 +55,12 @@ main a:hover {
55
55
  text-decoration: underline;
56
56
  }
57
57
 
58
+ select {
59
+ border-radius: 6px;
60
+ background-color: rgba(255, 255, 255, 0.05);
61
+ border: 1px solid rgba(255, 255, 255, 0.1);
62
+ }
63
+
58
64
  .actions a, .actions button, main button {
59
65
  display: inline-block;
60
66
  padding: 8px 16px;
@@ -35,8 +35,7 @@ jQuery(function($) {
35
35
  instance.popper.classList.add("*:rounded-md")
36
36
  instance.popper.classList.add("*:shadow-lg")
37
37
  instance.popper.classList.add("*:ring-1")
38
- instance.popper.classList.add("*:ring-white")
39
- instance.popper.classList.add("*:ring-opacity-5")
38
+ instance.popper.classList.add("*:ring-white/20")
40
39
  },
41
40
  });
42
41
  });
@@ -75,7 +75,7 @@ class Card:
75
75
  return self.link
76
76
 
77
77
  def get_current_display(self) -> str:
78
- return self.request.GET.get(f"{self.get_slug()}.display", "")
78
+ return self.request.query_params.get(f"{self.get_slug()}.display", "")
79
79
 
80
80
  def get_displays(self) -> list[str] | Enum | None:
81
81
  if hasattr(self.displays, "copy"):
@@ -125,15 +125,13 @@ class TrendCard(ChartCard):
125
125
  {
126
126
  "data": calculate_trend_line(trend_data),
127
127
  "type": "line",
128
- "borderColor": "rgba(0, 0, 0, 0.3)",
128
+ "borderColor": "rgba(255, 255, 255, 0.3)",
129
129
  "borderWidth": 2,
130
130
  "fill": False,
131
131
  "pointRadius": 0, # Optional: Hide points
132
132
  },
133
133
  ],
134
134
  },
135
- # Hide the label
136
- # "options": {"legend": {"display": False}},
137
135
  # Hide the scales
138
136
  "options": {
139
137
  "plugins": {"legend": {"display": False}},
@@ -147,7 +145,7 @@ class TrendCard(ChartCard):
147
145
  },
148
146
  "maintainAspectRatio": False,
149
147
  "elements": {
150
- "bar": {"borderRadius": "3", "backgroundColor": "rgb(28, 25, 23)"}
148
+ "bar": {"borderRadius": "3", "backgroundColor": "#d6d6d6"}
151
149
  },
152
150
  },
153
151
  }
@@ -0,0 +1,4 @@
1
+ ADMIN_TOOLBAR_CLASS = "plain.admin.toolbar.Toolbar"
2
+ ADMIN_TOOLBAR_VERSION: str = "dev"
3
+
4
+ ADMIN_QUERYSTATS_IGNORE_URLS: list[str] = ["/assets/.*"]
@@ -12,7 +12,7 @@ class ImpersonateStartView(View):
12
12
  impersonator = getattr(self.request, "impersonator", self.request.user)
13
13
  if impersonator and can_be_impersonator(impersonator):
14
14
  self.request.session[IMPERSONATE_KEY] = self.url_kwargs["pk"]
15
- return ResponseRedirect(self.request.GET.get("next", "/"))
15
+ return ResponseRedirect(self.request.query_params.get("next", "/"))
16
16
 
17
17
  return ResponseForbidden()
18
18
 
@@ -20,4 +20,4 @@ class ImpersonateStartView(View):
20
20
  class ImpersonateStopView(View):
21
21
  def get(self):
22
22
  self.request.session.pop(IMPERSONATE_KEY)
23
- return ResponseRedirect(self.request.GET.get("next", "/"))
23
+ return ResponseRedirect(self.request.query_params.get("next", "/"))
@@ -8,6 +8,8 @@ from plain.utils.functional import cached_property
8
8
 
9
9
  IGNORE_STACK_FILES = [
10
10
  "threading",
11
+ "concurrent/futures",
12
+ "functools.py",
11
13
  "socketserver",
12
14
  "wsgiref",
13
15
  "gunicorn",
@@ -15,11 +17,8 @@ IGNORE_STACK_FILES = [
15
17
  "sentry_sdk",
16
18
  "querystats/core",
17
19
  "plain/template/base",
18
- "plain/utils/decorators",
19
- "plain/db",
20
- "plain/utils/functional",
21
- "plain/core/servers",
22
- "plain/core/handlers",
20
+ "plain/models",
21
+ "plain/internal",
23
22
  ]
24
23
 
25
24
 
@@ -74,7 +73,7 @@ class QueryStats:
74
73
 
75
74
  # if many, then X times is len(params)
76
75
 
77
- current_query["result"] = result
76
+ # current_query["result"] = result
78
77
 
79
78
  current_query["duration"] = time.monotonic() - start
80
79
 
@@ -126,7 +125,7 @@ class QueryStats:
126
125
  "num_duplicate_queries": self.num_duplicate_queries,
127
126
  }
128
127
 
129
- def as_context_dict(self):
128
+ def as_context_dict(self, request):
130
129
  # If we don't create a dict, the instance of this class
131
130
  # is lost before we can use it in the template
132
131
  for query in self.queries:
@@ -137,10 +136,15 @@ class QueryStats:
137
136
  if duplicates:
138
137
  query["duplicate_count"] = duplicates
139
138
 
140
- summary = self.as_summary_dict()
141
-
142
139
  return {
143
- **summary,
140
+ **self.as_summary_dict(),
141
+ "request": {
142
+ "path": request.path,
143
+ "method": request.method,
144
+ "headers": dict(request.headers),
145
+ "unique_id": request.unique_id,
146
+ },
147
+ "timestamp": time.time(),
144
148
  "total_time_display": self.total_time_display,
145
149
  "queries": self.queries,
146
150
  }
@@ -1,26 +1,19 @@
1
1
  import json
2
2
  import logging
3
3
  import re
4
- import threading
5
4
 
6
- from plain.http import ResponseRedirect
7
5
  from plain.json import PlainJSONEncoder
8
6
  from plain.models import connection
9
7
  from plain.runtime import settings
10
- from plain.urls import reverse
11
8
 
12
9
  from .core import QueryStats
13
10
 
14
11
  try:
15
- try:
16
- import psycopg
17
- except ImportError:
18
- import psycopg2 as psycopg
12
+ import psycopg
19
13
  except ImportError:
20
14
  psycopg = None
21
15
 
22
16
  logger = logging.getLogger(__name__)
23
- _local = threading.local()
24
17
 
25
18
 
26
19
  class QueryStatsJSONEncoder(PlainJSONEncoder):
@@ -28,8 +21,11 @@ class QueryStatsJSONEncoder(PlainJSONEncoder):
28
21
  try:
29
22
  return super().default(obj)
30
23
  except TypeError:
31
- if psycopg and isinstance(obj, psycopg._json.Json):
32
- return obj.adapted
24
+ print(type(obj))
25
+ if psycopg and isinstance(obj, psycopg.types.json.Json):
26
+ return obj.obj
27
+ elif psycopg and isinstance(obj, psycopg.types.json.Jsonb):
28
+ return obj.obj
33
29
  else:
34
30
  raise
35
31
 
@@ -38,7 +34,7 @@ class QueryStatsMiddleware:
38
34
  def __init__(self, get_response):
39
35
  self.get_response = get_response
40
36
  self.ignore_url_patterns = [
41
- re.compile(url) for url in settings.QUERYSTATS_IGNORE_URLS
37
+ re.compile(url) for url in settings.ADMIN_QUERYSTATS_IGNORE_URLS
42
38
  ]
43
39
 
44
40
  def should_ignore_request(self, request):
@@ -49,41 +45,49 @@ class QueryStatsMiddleware:
49
45
  return False
50
46
 
51
47
  def __call__(self, request):
52
- if request.GET.get("querystats") == "disable":
48
+ """
49
+ Enables querystats for the current request.
50
+
51
+ If DEBUG or an admin, then Server-Timing headers are always added to the response.
52
+ Full querystats are only stored in the session if they are manually enabled.
53
+ """
54
+
55
+ if self.should_ignore_request(request):
53
56
  return self.get_response(request)
54
57
 
55
- querystats = QueryStats(
56
- # Only want these if we're getting ready to show it
57
- include_tracebacks=request.GET.get("querystats") == "store"
58
- )
58
+ session_querystats_enabled = "querystats" in request.session
59
59
 
60
+ querystats = QueryStats(include_tracebacks=session_querystats_enabled)
60
61
  with connection.execute_wrapper(querystats):
61
62
  # Have to wrap this first call so it is included in the querystats,
62
63
  # but we don't have to wrap everything else unless we are admin or debug
63
64
  is_admin = self.is_admin_request(request)
64
65
 
65
- if (settings.DEBUG or is_admin) and not self.should_ignore_request(request):
66
- # Persist it on the thread
67
- _local.querystats = querystats
68
-
69
- with connection.execute_wrapper(_local.querystats):
66
+ if settings.DEBUG or is_admin:
67
+ with connection.execute_wrapper(querystats):
70
68
  response = self.get_response(request)
71
69
 
72
70
  if settings.DEBUG:
73
71
  # TODO logging settings
74
- logger.debug("Querystats: %s", _local.querystats)
72
+ logger.debug("Querystats: %s", querystats)
75
73
 
76
74
  # Make current querystats available on the current page
77
75
  # by using the server timing API which can be parsed client-side
78
- response.headers["Server-Timing"] = _local.querystats.as_server_timing()
76
+ response.headers["Server-Timing"] = querystats.as_server_timing()
79
77
 
80
- if request.GET.get("querystats") == "store":
81
- request.session["querystats"] = json.dumps(
82
- _local.querystats.as_context_dict(), cls=QueryStatsJSONEncoder
78
+ if session_querystats_enabled and querystats.num_queries > 0:
79
+ request.session["querystats"][request.unique_id] = json.dumps(
80
+ querystats.as_context_dict(request), cls=QueryStatsJSONEncoder
83
81
  )
84
- return ResponseRedirect(reverse("querystats:querystats"))
85
82
 
86
- del _local.querystats
83
+ # Keep 30 requests max, in case it is left on by accident
84
+ if len(request.session["querystats"]) > 30:
85
+ del request.session["querystats"][
86
+ list(request.session["querystats"])[0]
87
+ ]
88
+
89
+ # Did a deeper modification to the session dict...
90
+ request.session.modified = True
87
91
 
88
92
  return response
89
93
 
@@ -0,0 +1,48 @@
1
+ import json
2
+
3
+ from plain.auth.views import AuthViewMixin
4
+ from plain.http import ResponseRedirect
5
+ from plain.views import TemplateView
6
+
7
+
8
+ class QuerystatsView(AuthViewMixin, TemplateView):
9
+ template_name = "querystats/querystats.html"
10
+ admin_required = True
11
+
12
+ def get_template_context(self):
13
+ context = super().get_template_context()
14
+
15
+ querystats = self.request.session.get("querystats", {})
16
+
17
+ for request_id, json_data in querystats.items():
18
+ try:
19
+ querystats[request_id] = json.loads(json_data)
20
+ except json.JSONDecodeError:
21
+ # If decoding fails, remove the entry from the dictionary
22
+ del querystats[request_id]
23
+
24
+ # Order them by timestamp
25
+ querystats = dict(
26
+ sorted(
27
+ querystats.items(),
28
+ key=lambda item: item[1].get("timestamp", ""),
29
+ reverse=True,
30
+ )
31
+ )
32
+
33
+ context["querystats"] = querystats
34
+
35
+ return context
36
+
37
+ def post(self):
38
+ querystats_action = self.request.data["querystats_action"]
39
+
40
+ if querystats_action == "enable":
41
+ self.request.session.setdefault("querystats", {})
42
+ elif querystats_action == "clear":
43
+ self.request.session["querystats"] = {}
44
+ elif querystats_action == "disable" and "querystats" in self.request.session:
45
+ del self.request.session["querystats"]
46
+
47
+ # Redirect back to the page that submitted the form
48
+ return ResponseRedirect(self.request.data.get("redirect_url", "."))
@@ -22,7 +22,7 @@
22
22
  </head>
23
23
  <body class="flex min-h-screen bg-stone-950">
24
24
 
25
- <nav class="fixed top-0 left-0 right-0 h-14 px-4 py-2 flex items-center justify-between sm:justify-evenly text-sm text-white/70 space-x-3">
25
+ <nav class="fixed top-0 left-0 right-0 h-14 px-5 py-2 flex items-center justify-between sm:justify-evenly text-sm text-white/70 space-x-3">
26
26
  <div class="flex items-center space-x-2">
27
27
  <button type="button" data-toggle="#admin-sidebar,#admin-content" class="lg:hidden">
28
28
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-5 h-5 bi bi-list" viewBox="0 0 16 16">
@@ -70,8 +70,8 @@
70
70
  </nav>
71
71
 
72
72
  <div class="fixed top-14 bottom-2 left-2 right-2">
73
- <aside id="admin-sidebar" data-toggle-class="hidden" class="flex-col bg-stone-950 z-50 border-r border-white/10 lg:border-none justify-between flex-shrink-0 hidden w-52 pl-3.5 pr-5 pt-2 overflow-auto lg:flex absolute top-0 bottom-0">
74
- <div class="flex-grow">
73
+ <aside id="admin-sidebar" data-toggle-class="hidden" class="flex-col bg-stone-950 z-50 border-r border-white/10 lg:border-none justify-between flex-shrink-0 hidden w-52 overflow-auto lg:flex absolute top-0 bottom-0">
74
+ <div class="flex-grow pl-3.5 pr-5 pt-2">
75
75
  <div>
76
76
  {% for section, views in admin_registry.get_nav_sections().items() %}
77
77
  <div class="mt-4 text-xs tracking-wide uppercase text-stone-300/90">{{ section }}</div>
@@ -94,7 +94,7 @@
94
94
  <div class="text-xs tracking-wide text-stone-500">Recent</div>
95
95
  </div> -->
96
96
  </div>
97
- <div class="mt-8 flex flex-col text-sm pb-3 pt-3 text-stone-400 sticky bottom-0 bg-stone-950">
97
+ <div class="mt-8 flex flex-col text-sm pb-3 pt-3 text-stone-400 sticky bottom-0 bg-stone-950/95 pl-3.5 pr-5">
98
98
  <a class="sm:hidden py-1" href="{{ url('admin:search') }}">Global search</a>
99
99
  <div class="flex items-center justify-between space-x-1.5">
100
100
  <div class="flex items-center truncate">
@@ -127,7 +127,7 @@
127
127
  <h1 class="sm:text-xl text-white/90">
128
128
  {% block title %}{{ title }}{% endblock %}
129
129
  </h1>
130
- {% if description %}<p class="mt-1 text-sm text-gray-500">{{ description }}</p>{% endif %}
130
+ {% if description %}<p class="mt-1 text-sm text-white/50">{{ description }}</p>{% endif %}
131
131
  </div>
132
132
  </div>
133
133
  {% endblock %}
@@ -8,14 +8,14 @@
8
8
  <header class="flex justify-between items-center">
9
9
  <div>
10
10
  <h2 class="text-sm font-semibold">{{ title }}</h2>
11
- {% if description %}<p class="mt-1 text-xs text-gray-500">{{ description }}</p>{% endif %}
11
+ {% if description %}<p class="mt-1 text-xs text-white/50">{{ description }}</p>{% endif %}
12
12
  </div>
13
13
 
14
14
  {% if displays %}
15
15
  <select
16
16
  hx-get
17
17
  name="{{ slug }}.display"
18
- class="text-sm border-gray-200 rounded-md">
18
+ class="text-xs py-1.5">
19
19
  <option value="">(Reset to default)</option>
20
20
  {% for display in displays %}
21
21
  <option {% if display == current_display %}selected{% endif %}>{{ display }}</option>
@@ -9,7 +9,7 @@
9
9
  {% endif %}
10
10
 
11
11
  {% if link %}
12
- <a href="{{ link }}">{{ text }}</a>
12
+ <a class="text-xs hover:underline mt-2" href="{{ link }}">{{ text }}</a>
13
13
  {% elif text %}
14
14
  {{ text }}
15
15
  {% endif %}
@@ -27,7 +27,7 @@
27
27
  {% if actions %}
28
28
  <form method="POST" data-actions-form>
29
29
  {{ csrf_input }}
30
- <select name="action_name" class="text-sm border-gray-200 rounded-md">
30
+ <select name="action_name" class="text-sm">
31
31
  <option value="">Actions</option>
32
32
  {% for action in actions %}
33
33
  <option>{{ action }}</option>
@@ -40,7 +40,7 @@
40
40
 
41
41
  <form method="GET" class="inline-flex space-x-5">
42
42
  {% if displays %}
43
- <select data-autosubmit name="display" class="text-sm border-gray-200 rounded-md">
43
+ <select data-autosubmit name="display" class="text-sm">
44
44
  <option value="">Displays</option>
45
45
  {% for display in displays %}
46
46
  <option {% if display == current_display %}selected{% endif %}>{{ display }}</option>
@@ -119,7 +119,7 @@
119
119
 
120
120
  {% if table_style != "simple" and actions %}
121
121
  <td class="p-0 pl-1">
122
- <input data-action-checkbox class="rounded-sm" type="checkbox" name="{{ get_object_pk(object) }}" />
122
+ <input data-action-checkbox class="rounded-sm bg-white/10" type="checkbox" name="{{ get_object_pk(object) }}" />
123
123
  </td>
124
124
  {% endif %}
125
125
 
@@ -136,7 +136,7 @@
136
136
  {% set object_links = get_object_links(object) %}
137
137
  {% if object_links %}
138
138
  <td class="py-0">
139
- <button data-dropdown class="inline-flex rounded-md border border-transparent hover:border-white/10 hover:shadow-sm px-3 py-1.5 hover:bg-white/20 text-sm font-medium text-white/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
139
+ <button data-dropdown class="!bg-transparent inline-flex rounded-md border !border-transparent hover:!bg-white/10 hover:!border-white/10 px-3 py-1.5 text-sm font-medium text-white/80 focus:outline-none">
140
140
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
141
141
  <path d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>
142
142
  </svg>
@@ -161,7 +161,7 @@
161
161
  {% if table_style != "simple" %}
162
162
  <footer class="mt-4">
163
163
  <div class="flex items-center justify-between">
164
- <div class="py-2 text-sm text-gray-500">
164
+ <div class="py-2 text-sm text-white/50">
165
165
  {% if page.has_other_pages() %}
166
166
  Page {{ page.number }} of {{ page.paginator.num_pages }} ({{ page.paginator.count }} results)
167
167
  {% endif %}
@@ -174,7 +174,7 @@
174
174
  <form data-autosubmit method="GET">
175
175
  {% if show_search and search_query %}<input type="hidden" name="search" value="{{ search_query }}" />{% endif %}
176
176
  {% if displays and current_display %}<input type="hidden" name="display" value="{{ current_display }}" />{% endif %}
177
- <select name="page" class="text-xs border-white/10 bg-white/5 rounded-md">
177
+ <select name="page" class="text-xs">
178
178
  {% for page_num in page.paginator.page_range %}
179
179
  <option value="{{ page_num }}" {% if page_num == page.number %}selected{% endif %}>Page {{ page_num }}</option>
180
180
  {% endfor %}
@@ -0,0 +1 @@
1
+ <p class="mt-2 text-sm text-white/50">{{ help }}</p>
@@ -0,0 +1,110 @@
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="bg-stone-950 text-stone-300">
10
+
11
+ <div class="flex items-center justify-between px-6 py-4">
12
+ <h1 class="text-lg font-semibold">Querystats</h1>
13
+ <div class="flex items-center space-x-2">
14
+ <form method="post" action=".">
15
+ {{ csrf_input }}
16
+ <input type="hidden" name="querystats_action" value="clear">
17
+ <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>
18
+ </form>
19
+ <form method="post" action=".">
20
+ {{ csrf_input }}
21
+ <input type="hidden" name="querystats_action" value="disable">
22
+ <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>
23
+ </form>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="space-y-6 px-6 py-4">
28
+ {% for request_id, qs in querystats.items() %}
29
+ <div class="p-3 bg-white/5 rounded">
30
+ <div class="flex justify-between items-center">
31
+ <div>
32
+ <h2 class="font-medium"><span class="font-semibold">{{ qs.request.method }}</span> {{ qs.request.path }}</h2>
33
+ <p class="text-sm text-stone-400">{{ qs.summary }}</p>
34
+ </div>
35
+ <div class=text-xs>
36
+ <p>Request ID <code>{{ qs.request.unique_id }}</code></p>
37
+ <p>Timestamp {{ qs.timestamp }}</p>
38
+ <details>
39
+ <summary>Headers</summary>
40
+ <pre><code>{{ qs.request.get("headers", {})|pprint }}</code></pre>
41
+ </details>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="flex w-full mt-5 overflow-auto rounded-sm">
46
+ {% for query in qs.queries %}
47
+ <a href="#query-{{ loop.index }}"
48
+ {{ loop.cycle('class=\"h-4 bg-amber-300\"', 'class=\"h-4 bg-amber-400\"', 'class="h-4 bg-amber-500"', 'class="h-4 bg-amber-600"')|safe }}
49
+ title="[{{ query.duration_display }}] {{ query.sql_display }}"
50
+ style="width: {{ query.duration / qs.total_time * 100 }}%">
51
+ </a>
52
+ {% endfor %}
53
+ </div>
54
+
55
+ <div class="mt-4 space-y-3 text-xs">
56
+ {% for query in qs.queries %}
57
+ <details id="query-{{ loop.index }}" class="p-2 rounded bg-zinc-800">
58
+ <summary class="truncate">
59
+ <div class="float-right px-2 py-px mb-px ml-2 text-xs rounded-full bg-zinc-700">
60
+ <span>{{ query.duration_display }}</span>
61
+ {% if query.duplicate_count is defined %}
62
+ <span class="text-red-500">&nbsp; duplicated {{ query.duplicate_count }} times</span>
63
+ {% endif %}
64
+
65
+ {#
66
+ <div>many {{ query.many }}</div>
67
+ <div>result {{ query.result }}</div>
68
+ #}
69
+ </div>
70
+ <code class="font-mono">{{ query.sql }}</code>
71
+ </summary>
72
+ <div class="space-y-3 mt-3">
73
+ <div>
74
+ <pre><code class="font-mono whitespace-pre-wrap text-zinc-100">{{ query.sql_display }}</code></pre>
75
+ </div>
76
+ <div class="text-zinc-400">
77
+ <span class="font-medium">Parameters</span>
78
+ <pre><code class="font-mono">{{ query.params|pprint }}</code></pre>
79
+ </div>
80
+ <details>
81
+ <summary>Traceback</summary>
82
+ <pre><code class="block overflow-x-auto font-mono text-xs">{{ query.tb }}</code></pre>
83
+ </details>
84
+ </div>
85
+ </details>
86
+ {% else %}
87
+ <div>No queries...</div>
88
+ {% endfor %}
89
+ </div>
90
+ </div>
91
+
92
+ {% else %}
93
+
94
+ <div class="text-center">
95
+ {% if "querystats" in request.session %}
96
+ <div class="text-stone-500">Querystats are enabled but nothing has been tracked yet.</div>
97
+ {% else %}
98
+ <form method="post" action=".">
99
+ {{ csrf_input }}
100
+ <input type="hidden" name="querystats_action" value="enable">
101
+ <button type="submit" class="px-2 rounded-sm bg-stone-700 text-stone-300 hover:bg-stone-600 cursor-pointer whitespace-nowrap">Enable querystats</button>
102
+ </form>
103
+ {% endif %}
104
+ </div>
105
+
106
+ {% endfor %}
107
+ </div>
108
+
109
+ </body>
110
+ </html>
@@ -1,13 +1,25 @@
1
- <form
2
- data-querystats
3
- action="."
4
- method="get"
5
- target="querystats"
6
- class="relative group/querystats"
7
- style="display: none;">
8
- <input type="hidden" name="querystats" value="store">
9
- <button type="submit" class="px-2 py-px text-xs rounded-full bg-stone-700 text-stone-300 whitespace-nowrap" data-querystats-summary></button>
10
- <div data-querystats-list style="display: none;" class="absolute right-0 z-50 hidden translate-y-full -bottom-1 group/querystats-hover:block">
1
+ <div data-querystats class="relative group/querystats" style="display: none;">
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">
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
+ </a>
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-stone-700 text-stone-300 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 -translate-x-1/2 hidden -translate-y-full left-1/2 -top-1 group-hover/querystats:block">
11
23
  <div class="p-2 text-xs border rounded shadow-md bg-zinc-900 border-zinc-700"><table><tbody></tbody></table></div>
12
24
  </div>
13
25
  <script async defer>
@@ -15,9 +27,8 @@
15
27
  // https://bugs.webkit.org/show_bug.cgi?id=209216
16
28
  var querystatsTimings = [];
17
29
  function renderQuerystats() {
18
- // Render the most recent timing call
19
- const latestTiming = querystatsTimings[querystatsTimings.length - 1];
20
- let summary = latestTiming.description;
30
+ // Render the original timing call
31
+ let summary = querystatsTimings[0].description;
21
32
  if (querystatsTimings.length > 1) {
22
33
  summary += ` *`;
23
34
  }
@@ -47,7 +58,6 @@
47
58
  }
48
59
  }
49
60
  try {
50
- // Create the performance observer.
51
61
  const po = new PerformanceObserver((list) => {
52
62
  for (const entry of list.getEntries()) {
53
63
  if (!entry.serverTiming) {
@@ -55,15 +65,15 @@
55
65
  return;
56
66
  }
57
67
  for (const timing of entry.serverTiming) {
58
- if (querystatsTimings.length > 0) {
59
- if (querystatsTimings[querystatsTimings.length - 1] === timing) {
60
- // Skip duplicate timings (happens on initial load...)
61
- continue;
62
- }
63
- }
64
68
  if (timing.name === "querystats") {
65
69
  console.log("Querystats timing", entry)
66
70
  timing.url = entry.name; // Store this for reference later
71
+ for (const existingTiming of querystatsTimings) {
72
+ if (existingTiming == timing) {
73
+ // Skip duplicate timings (happens on initial load...)
74
+ return;
75
+ }
76
+ }
67
77
  querystatsTimings.push(timing);
68
78
  renderQuerystats();
69
79
  }
@@ -76,4 +86,4 @@
76
86
  // Do nothing if the browser doesn't support this API.
77
87
  }
78
88
  </script>
79
- </form>
89
+ </div>
@@ -12,10 +12,10 @@ class ToolbarExtension(InclusionTagExtension):
12
12
  template_name = "toolbar/toolbar.html"
13
13
 
14
14
  def get_context(self, context, *args, **kwargs):
15
- if isinstance(settings.TOOLBAR_CLASS, str):
16
- cls = import_string(settings.TOOLBAR_CLASS)
15
+ if isinstance(settings.ADMIN_TOOLBAR_CLASS, str):
16
+ cls = import_string(settings.ADMIN_TOOLBAR_CLASS)
17
17
  else:
18
- cls = settings.TOOLBAR_CLASS
18
+ cls = settings.ADMIN_TOOLBAR_CLASS
19
19
  context.vars["toolbar"] = cls(request=context["request"])
20
20
  return context
21
21
 
@@ -8,7 +8,7 @@ from plain.urls.exceptions import Resolver404
8
8
  class Toolbar:
9
9
  def __init__(self, request):
10
10
  self.request = request
11
- self.version = settings.TOOLBAR_VERSION
11
+ self.version = settings.ADMIN_TOOLBAR_VERSION
12
12
  self.metadata = {
13
13
  "Request ID": request.unique_id,
14
14
  }
@@ -27,7 +27,7 @@ class AdminSearchView(AdminView):
27
27
  def get_template_context(self):
28
28
  context = super().get_template_context()
29
29
  context["searchable_views"] = registry.get_searchable_views()
30
- context["global_search_query"] = self.request.GET.get("query", "")
30
+ context["global_search_query"] = self.request.query_params.get("query", "")
31
31
  return context
32
32
 
33
33
 
@@ -77,7 +77,7 @@ class AdminModelListView(AdminListView):
77
77
  def get_template_context(self):
78
78
  context = super().get_template_context()
79
79
 
80
- order_by = self.request.GET.get("order_by", "")
80
+ order_by = self.request.query_params.get("order_by", "")
81
81
  if order_by.startswith("-"):
82
82
  order_by_field = order_by[1:]
83
83
  order_by_direction = "-"
@@ -102,7 +102,7 @@ class AdminModelListView(AdminListView):
102
102
  return self.model.objects.all()
103
103
 
104
104
  def order_queryset(self, queryset):
105
- if order_by := self.request.GET.get("order_by"):
105
+ if order_by := self.request.query_params.get("order_by"):
106
106
  queryset = queryset.order_by(order_by)
107
107
  elif self.queryset_order:
108
108
  queryset = queryset.order_by(*self.queryset_order)
@@ -110,7 +110,7 @@ class AdminModelListView(AdminListView):
110
110
  return queryset
111
111
 
112
112
  def search_queryset(self, queryset):
113
- if search := self.request.GET.get("search"):
113
+ if search := self.request.query_params.get("search"):
114
114
  filters = Q()
115
115
  for field in self.search_fields:
116
116
  filters |= Q(**{f"{field}__icontains": search})
@@ -25,14 +25,14 @@ class AdminListView(HTMXViewMixin, AdminView):
25
25
  context = super().get_template_context()
26
26
 
27
27
  # Make this available on self for usage in get_objects and other methods
28
- self.display = self.request.GET.get("display", "")
28
+ self.display = self.request.query_params.get("display", "")
29
29
 
30
30
  # Make this available to get_displays and stuff
31
31
  self.objects = self.get_objects()
32
32
 
33
- page_size = self.request.GET.get("page_size", self.page_size)
33
+ page_size = self.request.query_params.get("page_size", self.page_size)
34
34
  paginator = Paginator(self.objects, page_size)
35
- self._page = paginator.get_page(self.request.GET.get("page", 1))
35
+ self._page = paginator.get_page(self.request.query_params.get("page", 1))
36
36
 
37
37
  context["paginator"] = paginator
38
38
  context["page"] = self._page
@@ -44,7 +44,7 @@ class AdminListView(HTMXViewMixin, AdminView):
44
44
  context["current_display"] = self.display
45
45
 
46
46
  # Implement search yourself in get_objects
47
- context["search_query"] = self.request.GET.get("search", "")
47
+ context["search_query"] = self.request.query_params.get("search", "")
48
48
  context["show_search"] = self.show_search
49
49
 
50
50
  context["table_style"] = getattr(self, "_table_style", "default")
@@ -78,10 +78,10 @@ class AdminListView(HTMXViewMixin, AdminView):
78
78
 
79
79
  def post(self) -> Response:
80
80
  # won't be "key" anymore, just list
81
- action_name = self.request.POST.get("action_name")
81
+ action_name = self.request.data.get("action_name")
82
82
  actions = self.get_actions()
83
83
  if action_name and action_name in actions:
84
- target_pks = self.request.POST["action_pks"].split(",")
84
+ target_pks = self.request.data["action_pks"].split(",")
85
85
  response = self.perform_action(action_name, target_pks)
86
86
  if response:
87
87
  return response
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.admin"
3
- version = "0.25.1"
3
+ version = "0.27.0"
4
4
  description = "Admin dashboard and tools for Plain."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  license = "BSD-3-Clause"
@@ -1,4 +0,0 @@
1
- TOOLBAR_CLASS = "plain.admin.toolbar.Toolbar"
2
- TOOLBAR_VERSION: str = "dev"
3
-
4
- QUERYSTATS_IGNORE_URLS: list[str] = ["/assets/.*"]
@@ -1,27 +0,0 @@
1
- import json
2
-
3
- from plain.auth.views import AuthViewMixin
4
- from plain.views import TemplateView
5
-
6
-
7
- class QuerystatsView(AuthViewMixin, TemplateView):
8
- template_name = "querystats/querystats.html"
9
- admin_required = True # allow impersonator?
10
-
11
- def get_template_context(self):
12
- context = super().get_template_context()
13
-
14
- stored_querystats = self.request.session.get(
15
- "querystats"
16
- ) # Not popping so page can be reloaded
17
- if stored_querystats:
18
- # dates won't come back as Python dates...
19
- stored_querystats = json.loads(stored_querystats)
20
- context["querystats"] = stored_querystats
21
-
22
- return context
23
-
24
- def get_querystats(self):
25
- from .middleware import _local
26
-
27
- return _local.querystats
@@ -1 +0,0 @@
1
- <p class="mt-2 text-sm text-gray-500">{{ help }}</p>
@@ -1,78 +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>Document</title>
7
- {% tailwind_css %}
8
- </head>
9
- <body>
10
-
11
- <div class="px-6 py-4">
12
- <div class="flex items-center justify-between">
13
- <h2 class="text-xl font-medium">Query stats for {{ request.path }}</h2>
14
- <div class="flex items-center">
15
- <div class="pt-1">
16
- {{ querystats.summary }}
17
- </div>
18
-
19
- <form action="." method="get">
20
- <input type="hidden" name="querystats" value="store">
21
- <button type="submit" class="flex items-center px-3 py-2 ml-4 text-sm rounded-full bg-zinc-600 hover:bg-zinc-500">
22
- <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
23
- reload
24
- </button>
25
- </form>
26
- </div>
27
- </div>
28
- {#
29
- <div class="mt-2 font-mono text-xs">
30
- {{ querystats_resolver_match }}
31
- and template {{ querystats_template_name }}
32
- </div>
33
- #}
34
-
35
- <div class="flex w-full mt-5 overflow-auto rounded-sm">
36
- {% for query in querystats.queries %}
37
- <a href="#query-{{ loop.index }}"
38
- {{ loop.cycle('class=\"h-4 bg-amber-400\"', 'class="h-4 bg-amber-500"', 'class="h-4 bg-amber-600"')|safe }}
39
- title="[{{ query.duration_display }}] {{ query.sql_display }}"
40
- style="width: {{ query.duration / querystats.total_time * 100 }}%">
41
- </a>
42
- {% endfor %}
43
- </div>
44
-
45
- <div class="mt-4 space-y-4 text-sm">
46
- {% for query in querystats.queries %}
47
- <div id="query-{{ loop.index }}" class="p-2 rounded bg-zinc-800">
48
- <div class="float-right px-2 py-px mb-px ml-2 text-xs rounded-full bg-zinc-700">
49
- <span>{{ query.duration_display }}</span>
50
- {% if query.duplicate_count is defined %}
51
- <span class="text-red-500">&nbsp; duplicated {{ query.duplicate_count }} times</span>
52
- {% endif %}
53
-
54
- {#
55
- <div>many {{ query.many }}</div>
56
- <div>result {{ query.result }}</div>
57
- #}
58
- </div>
59
- <div>
60
- <pre><code class="font-mono whitespace-pre-wrap text-zinc-100">{{ query.sql_display }}</code></pre>
61
- </div>
62
- <div class="mt-3 text-zinc-400">
63
- <span class="font-medium">Parameters</span>
64
- <pre><code class="font-mono">{{ query.params|pprint }}</code></pre>
65
- </div>
66
- <details class="mt-3">
67
- <summary>Traceback</summary>
68
- <pre><code class="block overflow-x-auto font-mono text-xs">{{ query.tb }}</code></pre>
69
- </details>
70
- </div>
71
- {% else %}
72
- <div>No queries...</div>
73
- {% endfor %}
74
- </div>
75
- </div>
76
-
77
- </body>
78
- </html>
File without changes
File without changes
File without changes