varanus 0.1.0.dev1__tar.gz → 0.1.0.dev2__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 (74) hide show
  1. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/PKG-INFO +2 -2
  2. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/pyproject.toml +2 -2
  3. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/events.py +10 -0
  4. varanus-0.1.0.dev2/src/varanus/server/migrations/0003_alter_log_level.py +28 -0
  5. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/models.py +19 -7
  6. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/search/base.py +16 -4
  7. varanus-0.1.0.dev2/src/varanus/server/search/facet.py +63 -0
  8. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/base.html +1 -5
  9. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/environment_nodes.html +6 -0
  10. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/error.html +49 -0
  11. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/log.html +36 -0
  12. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/metric.html +40 -0
  13. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/node.html +44 -0
  14. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/node_environments.html +6 -0
  15. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/query.html +31 -0
  16. varanus-0.1.0.dev2/src/varanus/server/templates/site/details/request.html +136 -0
  17. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/errors.html +1 -1
  18. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/logs.html +2 -2
  19. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/metrics.html +1 -1
  20. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/overview.html +10 -2
  21. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/queries.html +1 -1
  22. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/site/requests.html +1 -1
  23. varanus-0.1.0.dev2/src/varanus/server/templatetags/varanus.py +13 -0
  24. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/urls.py +22 -0
  25. varanus-0.1.0.dev2/src/varanus/server/views/__init__.py +0 -0
  26. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/views/site.py +57 -4
  27. varanus-0.1.0.dev1/src/varanus/server/search/facet.py +0 -59
  28. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/error.html +0 -1
  29. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/log.html +0 -1
  30. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/metric.html +0 -1
  31. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/query.html +0 -1
  32. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/request.html +0 -44
  33. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/README.md +0 -0
  34. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/__init__.py +0 -0
  35. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/__init__.py +0 -0
  36. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/apps.py +0 -0
  37. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/client.py +0 -0
  38. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/context.py +0 -0
  39. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/loggers.py +0 -0
  40. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/middleware.py +0 -0
  41. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/transport/__init__.py +0 -0
  42. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/transport/base.py +0 -0
  43. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/transport/database.py +0 -0
  44. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/client/transport/http.py +0 -0
  45. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/__init__.py +0 -0
  46. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/__main__.py +0 -0
  47. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/admin.py +0 -0
  48. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/apps.py +0 -0
  49. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/asgi.py +0 -0
  50. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/context_processors.py +0 -0
  51. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/management/__init__.py +0 -0
  52. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/management/commands/__init__.py +0 -0
  53. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/management/commands/migrateall.py +0 -0
  54. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/middleware.py +0 -0
  55. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/migrations/0001_initial.py +0 -0
  56. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/migrations/0002_context_node_error_node_log_node_metric_node_and_more.py +0 -0
  57. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/migrations/__init__.py +0 -0
  58. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/router.py +0 -0
  59. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/search/__init__.py +0 -0
  60. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/search/date.py +0 -0
  61. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/settings.py +0 -0
  62. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/static/css/varanus.css +0 -0
  63. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/static/js/varanus.js +0 -0
  64. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/base.html +0 -0
  65. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/dashboard.html +0 -0
  66. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/registration/login.html +0 -0
  67. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/search/daterange.html +0 -0
  68. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/templates/search/multifacet.html +0 -0
  69. {varanus-0.1.0.dev1/src/varanus/server/views → varanus-0.1.0.dev2/src/varanus/server/templatetags}/__init__.py +0 -0
  70. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/views/api.py +0 -0
  71. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/views/base.py +0 -0
  72. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/views/dashboard.py +0 -0
  73. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/server/wsgi.py +0 -0
  74. {varanus-0.1.0.dev1 → varanus-0.1.0.dev2}/src/varanus/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: varanus
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev2
4
4
  Summary: Django application monitoring.
5
5
  Requires-Dist: httpx>=0.27.0
6
6
  Requires-Dist: msgspec>=0.19.0
@@ -11,7 +11,7 @@ Requires-Dist: granian>=2.4.2 ; extra == 'server'
11
11
  Requires-Dist: psycopg[binary]>=3.2.1 ; extra == 'server'
12
12
  Requires-Dist: websockets>=13.0 ; extra == 'server'
13
13
  Requires-Dist: whitenoise>=6.7.0 ; extra == 'server'
14
- Requires-Python: >=3.13
14
+ Requires-Python: >=3.11
15
15
  Provides-Extra: server
16
16
  Description-Content-Type: text/markdown
17
17
 
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "varanus"
3
- version = "0.1.0.dev1"
3
+ version = "0.1.0.dev2"
4
4
  description = "Django application monitoring."
5
5
  readme = "README.md"
6
- requires-python = ">=3.13"
6
+ requires-python = ">=3.11"
7
7
  dependencies = [
8
8
  "httpx>=0.27.0",
9
9
  "msgspec>=0.19.0",
@@ -1,3 +1,4 @@
1
+ import inspect
1
2
  import logging
2
3
  from datetime import datetime, timezone
3
4
 
@@ -25,6 +26,7 @@ class ErrorLine(Struct):
25
26
  lineno: int | None
26
27
  function: str | None
27
28
  module: str | None
29
+ linesrc: str | None
28
30
  locals: dict[str, str]
29
31
 
30
32
 
@@ -52,6 +54,13 @@ class Error(Event):
52
54
  abs_path = f_code.co_filename if f_code else None
53
55
  function = f_code.co_name if f_code else None
54
56
  lineno = getattr(tb, "tb_lineno", None)
57
+ linesrc = None
58
+ if f_code and lineno:
59
+ try:
60
+ source, start = inspect.getsourcelines(f_code)
61
+ linesrc = source[lineno - start].strip()
62
+ except (OSError, TypeError):
63
+ pass
55
64
  module = f_globals.get("__name__", "")
56
65
  lines.append(
57
66
  ErrorLine(
@@ -59,6 +68,7 @@ class Error(Event):
59
68
  lineno=lineno,
60
69
  function=function,
61
70
  module=module,
71
+ linesrc=linesrc,
62
72
  locals={name: repr(val) for name, val in f_locals.items()},
63
73
  )
64
74
  )
@@ -0,0 +1,28 @@
1
+ # Generated by Django 5.2.8 on 2025-11-17 19:51
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("varanus", "0002_context_node_error_node_log_node_metric_node_and_more"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AlterField(
13
+ model_name="log",
14
+ name="level",
15
+ field=models.IntegerField(
16
+ blank=True,
17
+ choices=[
18
+ (50, "CRITICAL"),
19
+ (40, "ERROR"),
20
+ (30, "WARNING"),
21
+ (20, "INFO"),
22
+ (10, "DEBUG"),
23
+ (0, "NOTSET"),
24
+ ],
25
+ null=True,
26
+ ),
27
+ ),
28
+ ]
@@ -1,6 +1,5 @@
1
1
  import hashlib
2
2
  import http
3
- import logging
4
3
  import secrets
5
4
  from typing import Self
6
5
 
@@ -161,7 +160,16 @@ class Node(EnvironmentModel):
161
160
  ]
162
161
 
163
162
  def __str__(self):
164
- return self.name
163
+ return f"{self.environment}@{self.name}"
164
+
165
+ def get_absolute_url(self):
166
+ return reverse(
167
+ "node-details",
168
+ kwargs={
169
+ "slug": self.site.slug,
170
+ "pk": self.pk,
171
+ },
172
+ )
165
173
 
166
174
  @classmethod
167
175
  def update(cls, info: events.NodeInfo, site: Site, environment: str = ""):
@@ -450,9 +458,17 @@ class Error(ContextualModel, FingerprintModel):
450
458
 
451
459
 
452
460
  class Log(ContextualModel, FingerprintModel):
461
+ class Level(models.IntegerChoices):
462
+ CRITICAL = 50, "CRITICAL"
463
+ ERROR = 40, "ERROR"
464
+ WARNING = 30, "WARNING"
465
+ INFO = 20, "INFO"
466
+ DEBUG = 10, "DEBUG"
467
+ NOTSET = 0, "NOTSET"
468
+
453
469
  message = models.TextField()
454
470
  name = models.CharField(max_length=200, blank=True)
455
- level = models.IntegerField(null=True, blank=True)
471
+ level = models.IntegerField(null=True, blank=True, choices=Level)
456
472
  file = models.TextField(blank=True)
457
473
  lineno = models.IntegerField(null=True, blank=True)
458
474
  error = models.ForeignKey(
@@ -481,10 +497,6 @@ class Log(ContextualModel, FingerprintModel):
481
497
  **extra,
482
498
  )
483
499
 
484
- @property
485
- def level_name(self):
486
- return logging.getLevelName(self.level) if self.level is not None else "UNKNOWN"
487
-
488
500
  def fingerprint_parts(self):
489
501
  return (self.message, self.name, self.level, self.file, self.lineno)
490
502
 
@@ -1,11 +1,12 @@
1
- from typing import ClassVar, Mapping, Self
1
+ from typing import Callable, ClassVar, Literal, Mapping, Self, TypeAlias
2
2
 
3
3
  from django.db.models import QuerySet
4
4
  from django.http import HttpRequest
5
5
  from django.template import loader
6
6
  from django.utils.datastructures import MultiValueDict
7
+ from django.utils.functional import Promise
7
8
 
8
- type StringValues = dict[str, list[str]]
9
+ StringValues: TypeAlias = dict[str, list[str]]
9
10
 
10
11
 
11
12
  def string_data(data) -> StringValues:
@@ -26,12 +27,23 @@ def string_data(data) -> StringValues:
26
27
  class SearchField:
27
28
  template_name: str
28
29
 
29
- def __init__(self, label=None, field_name=None):
30
+ def __init__(
31
+ self,
32
+ label=None,
33
+ field_name=None,
34
+ empty_label="NULL",
35
+ # These belong on Facet
36
+ choice_label: Callable[..., str | Promise] | None = None,
37
+ order: Literal["value", "count"] = "value",
38
+ ):
30
39
  self.name = ""
31
40
  self.label = label or ""
32
41
  self.field_name = field_name or ""
33
42
  self.prefix = ""
34
43
  self.id = ""
44
+ self.empty_label = empty_label
45
+ self.choice_label = choice_label
46
+ self.order = order
35
47
 
36
48
  def __set_name__(self, owner, name: str):
37
49
  # print("__set_name__", owner, name)
@@ -40,7 +52,7 @@ class SearchField:
40
52
  self.prefix = f"{name}_"
41
53
  self.id = f"id_{name}"
42
54
  if not self.label:
43
- self.label = name.capitalize()
55
+ self.label = name.capitalize().replace("__", " ")
44
56
  if not self.field_name:
45
57
  self.field_name = name
46
58
  if not hasattr(owner, "_fields"):
@@ -0,0 +1,63 @@
1
+ import functools
2
+ import operator
3
+ from typing import Any, ClassVar
4
+
5
+ from django.db.models import Count, Q, QuerySet
6
+
7
+ from .base import SearchField, StringValues
8
+
9
+
10
+ class Choice:
11
+ id: str
12
+ value: str
13
+ label: str
14
+ count: int
15
+
16
+ def __init__(self, field: SearchField, value: Any, count: int):
17
+ self.value = self.stringify(value)
18
+ if callable(field.choice_label):
19
+ self.label = field.choice_label(value)
20
+ else:
21
+ self.label = self.value or field.empty_label
22
+ self.id = field.id + "_" + self.value.replace(" ", "")
23
+ self.count = count
24
+
25
+ @classmethod
26
+ def stringify(cls, value):
27
+ return str(value) if value is not None else ""
28
+
29
+
30
+ class Facet(SearchField):
31
+ choice_class: ClassVar[type[Choice]] = Choice
32
+
33
+ def get_choices(self, queryset):
34
+ choice_qs = queryset.values_list(self.field_name).annotate(num=Count("*"))
35
+ match self.order:
36
+ case "value":
37
+ choice_qs = choice_qs.order_by(self.field_name)
38
+ case "count":
39
+ choice_qs = choice_qs.order_by("-num")
40
+ return [self.choice_class(self, c, num) for c, num in choice_qs]
41
+
42
+
43
+ class MultiFacet(Facet):
44
+ template_name = "search/multifacet.html"
45
+
46
+ def apply(self, queryset: QuerySet, field_data: StringValues) -> QuerySet:
47
+ filters = []
48
+ if selected := set(field_data.get("s", [])):
49
+ if "" in selected:
50
+ selected.discard("")
51
+ filters.append(Q(**{f"{self.field_name}__isnull": True}))
52
+ if selected:
53
+ filters.append(Q(**{f"{self.field_name}__in": selected}))
54
+ if filters:
55
+ queryset = queryset.filter(functools.reduce(operator.or_, filters))
56
+ return queryset
57
+
58
+ def get_context(self, queryset: QuerySet, field_data: StringValues) -> dict:
59
+ return {
60
+ **super().get_context(queryset, field_data),
61
+ "choices": self.get_choices(queryset),
62
+ "selected": field_data.get("s", []),
63
+ }
@@ -32,6 +32,7 @@
32
32
  <form action="" method="get">
33
33
  {{ search.render|safe }}
34
34
  <button class="btn btn-primary">Filter</button>
35
+ <a href="{{ request.path }}" class="btn btn-secondary">Clear</a>
35
36
  </form>
36
37
  {% endblock search %}
37
38
  </div>
@@ -42,11 +43,6 @@
42
43
  </div>
43
44
 
44
45
  <div class="offcanvas offcanvas-end" style="width:33%" tabindex="-1" id="inspector">
45
- <div class="offcanvas-header">
46
- <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
47
- </div>
48
- <div class="offcanvas-body" id="inspector-content">
49
- </div>
50
46
  </div>
51
47
  {% endblock content %}
52
48
 
@@ -0,0 +1,6 @@
1
+ <div class="offcanvas-header">
2
+ <h5 class="offcanvas-title">{{ environment }}</h5>
3
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
4
+ </div>
5
+ <div class="offcanvas-body">
6
+ </div>
@@ -0,0 +1,49 @@
1
+ {% load i18n %}
2
+ {% load varanus %}
3
+
4
+ <div class="offcanvas-header">
5
+ <h5 class="offcanvas-title">
6
+ {% if context.request %}
7
+ <a href="{{ context.request.get_absolute_url }}" data-bs-target="#inspector" hx-get="{{ context.request.get_absolute_url }}" hx-target="#inspector"><i class="bi bi-chevron-left"></i> {{ context }}</a>
8
+ {% endif %}
9
+ </h5>
10
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
11
+ </div>
12
+ <div class="offcanvas-body">
13
+ <table class="table table-sm">
14
+ <tbody>
15
+ <tr>
16
+ <th>{% trans "Kind" %}</th>
17
+ <td>{{ object.kind }}</td>
18
+ </tr>
19
+ <tr>
20
+ <th>{% trans "Module" %}</th>
21
+ <td>{{ object.module }}</td>
22
+ </tr>
23
+ <tr>
24
+ <th>{% trans "Message" %}</th>
25
+ <td>{{ object.message }}</td>
26
+ </tr>
27
+ </tbody>
28
+ </table>
29
+
30
+ <ul class="list-unstyled">
31
+ {% for line in object.lines %}
32
+ <li>
33
+ <p class="mb-3 p-1 border-bottom">
34
+ <strong>{{ line.module }}.{{ line.function }}</strong><br />
35
+ <small class="text-secondary">{{ line.file|truncatepath }}:{{ line.lineno }}</small>
36
+ {% if line.linesrc %}
37
+ <br /><code>{{ line.linesrc }}</code>
38
+ {% endif %}
39
+ </p>
40
+ <dl class="ms-3">
41
+ {% for name, value in line.locals.items %}
42
+ <dt class="font-monospace"><code>{{ name }}</code></dt>
43
+ <dd class="ms-3"><pre>{{ value }}</pre></dd>
44
+ {% endfor %}
45
+ </dl>
46
+ </li>
47
+ {% endfor %}
48
+ </ul>
49
+ </div>
@@ -0,0 +1,36 @@
1
+ {% load i18n %}
2
+
3
+ <div class="offcanvas-header">
4
+ <h5 class="offcanvas-title">
5
+ {% if context.request %}
6
+ <a href="{{ context.request.get_absolute_url }}" data-bs-target="#inspector" hx-get="{{ context.request.get_absolute_url }}" hx-target="#inspector"><i class="bi bi-chevron-left"></i> {{ context }}</a>
7
+ {% endif %}
8
+ </h5>
9
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
10
+ </div>
11
+ <div class="offcanvas-body">
12
+ <table class="table table-sm">
13
+ <tbody>
14
+ <tr>
15
+ <th>{% trans "Name" %}</th>
16
+ <td>{{ object.name }}</td>
17
+ </tr>
18
+ <tr>
19
+ <th>{% trans "Level" %}</th>
20
+ <td>{{ object.get_level_display }}</td>
21
+ </tr>
22
+ <tr>
23
+ <th>{% trans "File" %}</th>
24
+ <td>{{ object.file }}:{{ object.lineno }}</td>
25
+ </tr>
26
+ <tr>
27
+ <th>{% trans "Message" %}</th>
28
+ <td>{{ object.message }}</td>
29
+ </tr>
30
+ <tr>
31
+ <th>{% trans "Error" %}</th>
32
+ <td>{{ object.error }}</td>
33
+ </tr>
34
+ </tbody>
35
+ </table>
36
+ </div>
@@ -0,0 +1,40 @@
1
+ {% load i18n %}
2
+
3
+ <div class="offcanvas-header">
4
+ <h5 class="offcanvas-title">
5
+ {% if context.request %}
6
+ <a href="{{ context.request.get_absolute_url }}" data-bs-target="#inspector" hx-get="{{ context.request.get_absolute_url }}" hx-target="#inspector"><i class="bi bi-chevron-left"></i> {{ context }}</a>
7
+ {% endif %}
8
+ </h5>
9
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
10
+ </div>
11
+ <div class="offcanvas-body">
12
+ <table class="table table-sm">
13
+ <tbody>
14
+ <tr>
15
+ <th>{% trans "Name" %}</th>
16
+ <td>{{ object.name }}</td>
17
+ </tr>
18
+ <tr>
19
+ <th>{% trans "Count" %}</th>
20
+ <td>{{ object.agg_count }}</td>
21
+ </tr>
22
+ <tr>
23
+ <th>{% trans "Sum" %}</th>
24
+ <td>{{ object.agg_sum }}</td>
25
+ </tr>
26
+ <tr>
27
+ <th>{% trans "Average" %}</th>
28
+ <td>{{ object.agg_avg }}</td>
29
+ </tr>
30
+ <tr>
31
+ <th>{% trans "Min" %}</th>
32
+ <td>{{ object.agg_min }}</td>
33
+ </tr>
34
+ <tr>
35
+ <th>{% trans "Max" %}</th>
36
+ <td>{{ object.agg_max }}</td>
37
+ </tr>
38
+ </tbody>
39
+ </table>
40
+ </div>
@@ -0,0 +1,44 @@
1
+ {% load i18n %}
2
+
3
+ <div class="offcanvas-header">
4
+ <h5 class="offcanvas-title">{{ node }}</h5>
5
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
6
+ </div>
7
+ <div class="offcanvas-body">
8
+ <table class="table table-sm table-striped">
9
+ <thead class="table-header">
10
+ <tr>
11
+ <th>{% trans "Package" %}</th>
12
+ <th>{% trans "Version" %}</th>
13
+ </tr>
14
+ </thead>
15
+ <tbody>
16
+ {% for p in node.packages.all %}
17
+ <tr>
18
+ <td>{{ p.package }}</td>
19
+ <td>{{ p.version }}</td>
20
+ </tr>
21
+ {% endfor %}
22
+ </tbody>
23
+ </table>
24
+
25
+ {% for update in node.updates.all %}
26
+ <h6><em>{{ update.timestamp }}</em></h6>
27
+ <dl>
28
+ {% if update.installed %}
29
+ <dt>{% trans "Installed" %}</dt>
30
+ <dd>{{ update.installed }}</dd>
31
+ {% endif %}
32
+
33
+ {% if update.updated %}
34
+ <dt>{% trans "Updated" %}</dt>
35
+ <dd>{{ update.updated }}</dd>
36
+ {% endif %}
37
+
38
+ {% if update.removed %}
39
+ <dt>{% trans "Removed" %}</dt>
40
+ <dd>{{ update.removed }}</dd>
41
+ {% endif %}
42
+ </dl>
43
+ {% endfor %}
44
+ </div>
@@ -0,0 +1,6 @@
1
+ <div class="offcanvas-header">
2
+ <h5 class="offcanvas-title">{{ name }}</h5>
3
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
4
+ </div>
5
+ <div class="offcanvas-body">
6
+ </div>
@@ -0,0 +1,31 @@
1
+ {% load i18n %}
2
+
3
+ <div class="offcanvas-header">
4
+ <h5 class="offcanvas-title">
5
+ {% if context.request %}
6
+ <a href="{{ context.request.get_absolute_url }}" data-bs-target="#inspector" hx-get="{{ context.request.get_absolute_url }}" hx-target="#inspector"><i class="bi bi-chevron-left"></i> {{ context }}</a>
7
+ {% endif %}
8
+ </h5>
9
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
10
+ </div>
11
+ <div class="offcanvas-body">
12
+ <table class="table table-sm">
13
+ <tbody>
14
+ <tr>
15
+ <th>{% trans "Database" %}</th>
16
+ <td>{{ object.db }}</td>
17
+ </tr>
18
+ <tr>
19
+ <th>{% trans "Elapsed" %}</th>
20
+ <td>{{ object.elapsed_ms }}ms</td>
21
+ </tr>
22
+ {% if object.params %}
23
+ <tr>
24
+ <th>{% trans "Parameters" %}</th>
25
+ <td>{{ object.params }}</td>
26
+ </tr>
27
+ {% endif %}
28
+ </tbody>
29
+ </table>
30
+ <code><pre>{{ object.sql_formatted }}</pre></code>
31
+ </div>
@@ -0,0 +1,136 @@
1
+ {% load i18n %}
2
+
3
+ <div class="offcanvas-header">
4
+ <h5 class="offcanvas-title">{{ object.method }} {{ object.path }}</h5>
5
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
6
+ </div>
7
+ <div class="offcanvas-body">
8
+ <table class="table table-sm">
9
+ <tbody>
10
+ <tr>
11
+ <th>{% trans "Host" %}</th>
12
+ <td>{{ object.host }}</td>
13
+ </tr>
14
+ <tr>
15
+ <th>{% trans "Node" %}</th>
16
+ <td>{{ object.node }}</td>
17
+ </tr>
18
+ <tr>
19
+ <th>{% trans "Request" %}</th>
20
+ <td>{{ object.method }} {{ object.path }}</td>
21
+ </tr>
22
+ <tr>
23
+ <th>{% trans "Status" %}</th>
24
+ <td>{{ object.http_status }}</td>
25
+ </tr>
26
+ <tr>
27
+ <th>{% trans "IP" %}</th>
28
+ <td>{{ object.ip }}</td>
29
+ </tr>
30
+ <tr>
31
+ <th>{% trans "User" %}</th>
32
+ <td>{{ object.user }}</td>
33
+ </tr>
34
+ </tbody>
35
+ </table>
36
+
37
+ {% if object.headers %}
38
+ <h5>{% trans "Headers" %}</h5>
39
+ <table class="table table-sm">
40
+ <tbody>
41
+ {% for name, value in object.headers.items %}
42
+ <tr>
43
+ <th>{{ name }}</th>
44
+ <td>{{ value }}</th>
45
+ </tr>
46
+ {% endfor %}
47
+ </tbody>
48
+ </table>
49
+ {% endif %}
50
+
51
+ {% if context.errors.exists %}
52
+ <table class="table table-sm">
53
+ <thead class="table-header">
54
+ <tr>
55
+ <th>{% trans "Error" %}</th>
56
+ <th>{% trans "Kind" %}</th>
57
+ <th>{% trans "Module" %}</th>
58
+ </tr>
59
+ </thead>
60
+ <tbody>
61
+ {% for err in context.errors.all %}
62
+ <tr>
63
+ <td>
64
+ <a href="{{ err.get_absolute_url }}" data-bs-target="#inspector" hx-get="{{ err.get_absolute_url }}" hx-target="#inspector">{{ err.message }}</a>
65
+ </td>
66
+ <td>{{ err.kind }}</td>
67
+ <td>{{ err.module }}</td>
68
+ {% endfor %}
69
+ </tbody>
70
+ </table>
71
+ {% endif %}
72
+
73
+ {% if context.logs.exists %}
74
+ <table class="table table-sm">
75
+ <thead class="table-header">
76
+ <tr>
77
+ <th>{% trans "Log" %}</th>
78
+ <th>{% trans "Level" %}</th>
79
+ </tr>
80
+ </thead>
81
+ <tbody>
82
+ {% for log in context.logs.all %}
83
+ <tr>
84
+ <td>
85
+ <a href="{{ log.get_absolute_url }}" data-bs-target="#inspector" hx-get="{{ log.get_absolute_url }}" hx-target="#inspector">{{ log.message }}</a>
86
+ </td>
87
+ <td>{{ log.get_level_display }}</td>
88
+ {% endfor %}
89
+ </tbody>
90
+ </table>
91
+ {% endif %}
92
+
93
+ {% if context.queries.exists %}
94
+ <table class="table table-sm">
95
+ <thead class="table-header">
96
+ <tr>
97
+ <th>{% trans "Query" %}</th>
98
+ <th>{% trans "Elapsed" %}</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody>
102
+ {% for query in context.queries.all %}
103
+ <tr>
104
+ <td>
105
+ <a href="{{ query.get_absolute_url }}" data-bs-target="#inspector" hx-get="{{ query.get_absolute_url }}" hx-target="#inspector">{{ query.sql_summary }}</a>
106
+ </td>
107
+ <td>{{ query.elapsed_ms }}ms</td>
108
+ {% endfor %}
109
+ </tbody>
110
+ </table>
111
+ {% endif %}
112
+
113
+ {% if context.metrics.exists %}
114
+ <table class="table table-sm">
115
+ <thead class="table-header">
116
+ <tr>
117
+ <th>{% trans "Metric" %}</th>
118
+ <th>{% trans "Count" %}</th>
119
+ <th>{% trans "Sum" %}</th>
120
+ <th>{% trans "Average" %}</th>
121
+ </tr>
122
+ </thead>
123
+ <tbody>
124
+ {% for m in context.metrics.all %}
125
+ <tr>
126
+ <td>
127
+ <a href="{{ m.get_absolute_url }}" data-bs-target="#inspector" hx-get="{{ m.get_absolute_url }}" hx-target="#inspector">{{ m.name }}</a>
128
+ </td>
129
+ <td>{{ m.agg_count }}</td>
130
+ <td>{{ m.agg_sum }}</td>
131
+ <td>{{ m.agg_avg }}</td>
132
+ {% endfor %}
133
+ </tbody>
134
+ </table>
135
+ {% endif %}
136
+ </div>
@@ -17,7 +17,7 @@
17
17
  {% for e in results %}
18
18
  <tr>
19
19
  <td>
20
- <a href="{{ e.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ e.get_absolute_url }}" hx-target="#inspector-content">{{ e.context }}</a>
20
+ <a href="{{ e.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ e.get_absolute_url }}" hx-target="#inspector">{{ e.context }}</a>
21
21
  </td>
22
22
  <td>{{ e.kind }}</td>
23
23
  <td>{{ e.message }}</td>
@@ -18,9 +18,9 @@
18
18
  {% for log in results %}
19
19
  <tr>
20
20
  <td>
21
- <a href="{{ log.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ log.get_absolute_url }}" hx-target="#inspector-content">{{ log.context }}</a>
21
+ <a href="{{ log.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ log.get_absolute_url }}" hx-target="#inspector">{{ log.context }}</a>
22
22
  </td>
23
- <td>{{ log.level_name }}</td>
23
+ <td>{{ log.get_level_display }}</td>
24
24
  <td>{{ log.message }}</td>
25
25
  <td>{{ log.name }}</td>
26
26
  <td>{{ log.file }}:{{ log.lineno }}</td>
@@ -20,7 +20,7 @@
20
20
  {% for m in results %}
21
21
  <tr>
22
22
  <td>
23
- <a href="{{ m.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ m.get_absolute_url }}" hx-target="#inspector-content">{{ m.context }}</a>
23
+ <a href="{{ m.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ m.get_absolute_url }}" hx-target="#inspector">{{ m.context }}</a>
24
24
  </td>
25
25
  <td>{{ m.name }}</td>
26
26
  <td>{{ m.agg_count }}</td>
@@ -26,6 +26,7 @@ varanus.client.setup(
26
26
  <tr>
27
27
  <th>{% trans "Environment" %}</th>
28
28
  <th>{% trans "Node" %}</th>
29
+ <th>{% trans "Packages" %}</th>
29
30
  <th>{% trans "Platform" %}</th>
30
31
  <th>{% trans "Python Version" %}</th>
31
32
  <th>{% trans "First Seen" %}</th>
@@ -35,8 +36,15 @@ varanus.client.setup(
35
36
  <tbody>
36
37
  {% for node in nodes %}
37
38
  <tr>
38
- <td>{{ node.environment }}</td>
39
- <td>{{ node.name }}</td>
39
+ <td>
40
+ <a href="{% url 'environment-nodes' site.slug node.environment %}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{% url 'environment-nodes' site.slug node.environment %}" hx-target="#inspector">{{ node.environment }}</a>
41
+ </td>
42
+ <td>
43
+ <a href="{% url 'node-environments' site.slug node.name %}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{% url 'node-environments' site.slug node.name %}" hx-target="#inspector">{{ node.name }}</a>
44
+ </td>
45
+ <td>
46
+ <a href="{% url 'node-details' site.slug node.pk %}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{% url 'node-details' site.slug node.pk %}" hx-target="#inspector">{{ node.num_packages }}</a>
47
+ </td>
40
48
  <td>{{ node.platform }}</td>
41
49
  <td>{{ node.python_version }}</td>
42
50
  <td>{{ node.first_seen }}</td>
@@ -20,7 +20,7 @@
20
20
  {% for q in results %}
21
21
  <tr>
22
22
  <td>
23
- <a href="{{ q.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ q.get_absolute_url }}" hx-target="#inspector-content">{{ q.context }}</a>
23
+ <a href="{{ q.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ q.get_absolute_url }}" hx-target="#inspector">{{ q.context }}</a>
24
24
  </td>
25
25
  <td>{{ q.command }}</td>
26
26
  <td>{{ q.db }}</td>
@@ -18,7 +18,7 @@
18
18
  {% for r in results %}
19
19
  <tr>
20
20
  <td>
21
- <a href="{{ r.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ r.get_absolute_url }}" hx-target="#inspector-content">{{ r.path }}</a>
21
+ <a href="{{ r.get_absolute_url }}" data-bs-toggle="offcanvas" data-bs-target="#inspector" hx-get="{{ r.get_absolute_url }}" hx-target="#inspector">{{ r.path }}</a>
22
22
  </td>
23
23
  <td>{{ r.size|filesizeformat }}</td>
24
24
  <td>{{ r.context.elapsed_ms }}ms</td>
@@ -0,0 +1,13 @@
1
+ from django import template
2
+
3
+ register = template.Library()
4
+
5
+
6
+ @register.filter
7
+ def truncatepath(path: str, num: int = 50):
8
+ if len(path) > num:
9
+ parts = path.split("/")
10
+ while len("/".join(parts)) > num:
11
+ parts.pop(0)
12
+ return "…" + "/".join(parts)
13
+ return path
@@ -29,6 +29,28 @@ urlpatterns = [
29
29
  site.Details.as_view(),
30
30
  name="event-details",
31
31
  ),
32
+ path(
33
+ "node/",
34
+ include(
35
+ [
36
+ path(
37
+ "name/<name>/",
38
+ site.NodeEnvironments.as_view(),
39
+ name="node-environments",
40
+ ),
41
+ path(
42
+ "environment/<environment>/",
43
+ site.EnvironmentNodes.as_view(),
44
+ name="environment-nodes",
45
+ ),
46
+ path(
47
+ "<pk>/",
48
+ site.NodeDetails.as_view(),
49
+ name="node-details",
50
+ ),
51
+ ]
52
+ ),
53
+ ),
32
54
  ]
33
55
  ),
34
56
  ),
@@ -1,13 +1,22 @@
1
1
  from typing import ClassVar
2
2
 
3
3
  from django.contrib.contenttypes.models import ContentType
4
- from django.db.models import OuterRef, QuerySet, Subquery
4
+ from django.db.models import Count, OuterRef, QuerySet, Subquery
5
+ from django.shortcuts import get_object_or_404
6
+ from django.utils.functional import Promise
5
7
 
6
- from ..models import Context
8
+ from ..models import Context, Log
7
9
  from ..search import DateRange, MultiFacet, Search
8
10
  from .base import SiteView
9
11
 
10
12
 
13
+ def level_name(level) -> str | Promise:
14
+ try:
15
+ return Log.Level(level).label
16
+ except ValueError:
17
+ return str(level)
18
+
19
+
11
20
  class Overview(SiteView):
12
21
  template_name = "site/overview.html"
13
22
 
@@ -18,6 +27,7 @@ class Overview(SiteView):
18
27
  .order_by("-timestamp")
19
28
  .values("timestamp")[:1]
20
29
  ),
30
+ num_packages=Count("packages"),
21
31
  )
22
32
  return {
23
33
  "host": self.request.get_host(),
@@ -36,7 +46,7 @@ class SearchView(SiteView):
36
46
  search = self.search_class(self.get_queryset(), self.request.GET)
37
47
  return {
38
48
  "search": search,
39
- "results": search.queryset(),
49
+ "results": search.queryset()[:50],
40
50
  }
41
51
 
42
52
 
@@ -46,7 +56,9 @@ class Logs(SearchView):
46
56
  class search_class(Search):
47
57
  timeframe = DateRange(field_name="timestamp")
48
58
  name = MultiFacet()
49
- level = MultiFacet()
59
+ level = MultiFacet(choice_label=level_name)
60
+ environment = MultiFacet()
61
+ node__name = MultiFacet()
50
62
 
51
63
  def get_queryset(self) -> QuerySet:
52
64
  return self.site.logs.select_related("context")
@@ -59,6 +71,8 @@ class Errors(SearchView):
59
71
  timeframe = DateRange(field_name="timestamp")
60
72
  kind = MultiFacet()
61
73
  module = MultiFacet()
74
+ environment = MultiFacet()
75
+ node__name = MultiFacet()
62
76
 
63
77
  def get_queryset(self) -> QuerySet:
64
78
  return self.site.errors.select_related("context")
@@ -70,6 +84,8 @@ class Requests(SearchView):
70
84
  class search_class(Search):
71
85
  timeframe = DateRange(field_name="timestamp")
72
86
  status = MultiFacet()
87
+ environment = MultiFacet()
88
+ node__name = MultiFacet()
73
89
 
74
90
  def get_queryset(self) -> QuerySet:
75
91
  return self.site.requests.select_related("context")
@@ -82,6 +98,8 @@ class Queries(SearchView):
82
98
  timeframe = DateRange(field_name="timestamp")
83
99
  type = MultiFacet(field_name="command")
84
100
  db = MultiFacet()
101
+ environment = MultiFacet()
102
+ node__name = MultiFacet()
85
103
 
86
104
  def get_queryset(self) -> QuerySet:
87
105
  return self.site.queries.select_related("context")
@@ -93,6 +111,8 @@ class Metrics(SearchView):
93
111
  class search_class(Search):
94
112
  timeframe = DateRange(field_name="timestamp")
95
113
  name = MultiFacet()
114
+ environment = MultiFacet()
115
+ node__name = MultiFacet()
96
116
 
97
117
  def get_queryset(self) -> QuerySet:
98
118
  return self.site.metrics.select_related("context")
@@ -109,4 +129,37 @@ class Details(SiteView):
109
129
  obj = ct.get_object_for_this_type(pk=self.kwargs["pk"])
110
130
  return {
111
131
  "object": obj,
132
+ "context": obj.context,
133
+ }
134
+
135
+
136
+ class NodeDetails(SiteView):
137
+ template_name = "site/details/node.html"
138
+
139
+ def get_context(self):
140
+ node = get_object_or_404(self.site.nodes, pk=self.kwargs["pk"])
141
+ return {
142
+ "node": node,
143
+ }
144
+
145
+
146
+ class NodeEnvironments(SiteView):
147
+ template_name = "site/details/node_environments.html"
148
+
149
+ def get_context(self):
150
+ return {
151
+ "name": self.kwargs["name"],
152
+ "nodes": self.site.nodes.filter(name__iexact=self.kwargs["name"]),
153
+ }
154
+
155
+
156
+ class EnvironmentNodes(SiteView):
157
+ template_name = "site/details/environment_nodes.html"
158
+
159
+ def get_context(self):
160
+ return {
161
+ "environment": self.kwargs["environment"],
162
+ "nodes": self.site.nodes.filter(
163
+ environment__iexact=self.kwargs["environment"]
164
+ ),
112
165
  }
@@ -1,59 +0,0 @@
1
- from typing import Any, ClassVar
2
-
3
- from django.db.models import Count, QuerySet
4
-
5
- from .base import SearchField, StringValues
6
-
7
-
8
- class Choice:
9
- id: str
10
- value: str
11
- label: str
12
- count: int
13
-
14
- def __init__(self, field: SearchField, value: Any):
15
- self.value = self.stringify(value)
16
- self.label = self.value
17
- self.id = field.id + "_" + self.value.replace(" ", "")
18
- self.count = 0
19
-
20
- @classmethod
21
- def stringify(cls, value):
22
- return str(value)
23
-
24
-
25
- class Facet(SearchField):
26
- choice_class: ClassVar[type[Choice]] = Choice
27
-
28
- def get_choices(self, queryset, with_counts=True):
29
- choices = [
30
- self.choice_class(self, c)
31
- for c in queryset.order_by(self.field_name)
32
- .values_list(self.field_name, flat=True)
33
- .distinct(self.field_name)
34
- ]
35
- if with_counts:
36
- counts = {
37
- self.choice_class.stringify(r[self.field_name]): r["num"]
38
- for r in queryset.values(self.field_name).annotate(num=Count("*"))
39
- }
40
- for c in choices:
41
- c.count = counts.get(c.value, 0)
42
- return choices
43
-
44
-
45
- class MultiFacet(Facet):
46
- template_name = "search/multifacet.html"
47
-
48
- def apply(self, queryset: QuerySet, field_data: StringValues) -> QuerySet:
49
- filters = {}
50
- if selected := field_data.get("s", []):
51
- filters[f"{self.field_name}__in"] = selected
52
- return queryset.filter(**filters)
53
-
54
- def get_context(self, queryset: QuerySet, field_data: StringValues) -> dict:
55
- return {
56
- **super().get_context(queryset, field_data),
57
- "choices": self.get_choices(queryset),
58
- "selected": field_data.get("s", []),
59
- }
@@ -1 +0,0 @@
1
- <code><pre>{{ object.sql_formatted }}</pre></code>
@@ -1,44 +0,0 @@
1
- {% load i18n %}
2
-
3
- <table class="table table-sm">
4
- <tbody>
5
- <tr>
6
- <th>{% trans "Host" %}</th>
7
- <td>{{ object.host }}</td>
8
- </tr>
9
- <tr>
10
- <th>{% trans "Node" %}</th>
11
- <td>{{ object.node }}</td>
12
- </tr>
13
- <tr>
14
- <th>{% trans "Request" %}</th>
15
- <td>{{ object.method }} {{ object.path }}</td>
16
- </tr>
17
- <tr>
18
- <th>{% trans "Status" %}</th>
19
- <td>{{ object.http_status }}</td>
20
- </tr>
21
- <tr>
22
- <th>{% trans "IP" %}</th>
23
- <td>{{ object.ip }}</td>
24
- </tr>
25
- <tr>
26
- <th>{% trans "User" %}</th>
27
- <td>{{ object.user }}</td>
28
- </tr>
29
- </tbody>
30
- </table>
31
-
32
- {% if object.headers %}
33
- <h5>{% trans "Headers" %}</h5>
34
- <table class="table table-sm">
35
- <tbody>
36
- {% for name, value in object.headers.items %}
37
- <tr>
38
- <th>{{ name }}</th>
39
- <td>{{ value }}</th>
40
- </tr>
41
- {% endfor %}
42
- </tbody>
43
- </table>
44
- {% endif %}
File without changes