varanus 0.1.0.dev1__tar.gz → 0.1.0.dev3__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 (75) hide show
  1. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/PKG-INFO +2 -2
  2. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/pyproject.toml +2 -2
  3. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/client/apps.py +1 -1
  4. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/client/client.py +33 -30
  5. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/client/context.py +11 -11
  6. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/client/middleware.py +6 -6
  7. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/client/transport/base.py +1 -1
  8. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/client/transport/database.py +4 -1
  9. varanus-0.1.0.dev3/src/varanus/client/transport/http.py +83 -0
  10. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/events.py +10 -0
  11. varanus-0.1.0.dev3/src/varanus/server/migrations/0003_alter_log_level.py +28 -0
  12. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/models.py +19 -7
  13. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/search/base.py +17 -7
  14. varanus-0.1.0.dev3/src/varanus/server/search/facet.py +63 -0
  15. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/site/base.html +1 -5
  16. varanus-0.1.0.dev3/src/varanus/server/templates/site/details/environment_nodes.html +6 -0
  17. varanus-0.1.0.dev3/src/varanus/server/templates/site/details/error.html +49 -0
  18. varanus-0.1.0.dev3/src/varanus/server/templates/site/details/log.html +36 -0
  19. varanus-0.1.0.dev3/src/varanus/server/templates/site/details/metric.html +40 -0
  20. varanus-0.1.0.dev3/src/varanus/server/templates/site/details/node.html +44 -0
  21. varanus-0.1.0.dev3/src/varanus/server/templates/site/details/node_environments.html +6 -0
  22. varanus-0.1.0.dev3/src/varanus/server/templates/site/details/query.html +31 -0
  23. varanus-0.1.0.dev3/src/varanus/server/templates/site/details/request.html +136 -0
  24. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/site/errors.html +1 -1
  25. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/site/logs.html +2 -2
  26. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/site/metrics.html +1 -1
  27. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/site/overview.html +10 -2
  28. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/site/queries.html +1 -1
  29. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/site/requests.html +1 -1
  30. varanus-0.1.0.dev3/src/varanus/server/templatetags/varanus.py +13 -0
  31. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/urls.py +22 -0
  32. varanus-0.1.0.dev3/src/varanus/server/views/__init__.py +0 -0
  33. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/views/api.py +20 -12
  34. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/views/site.py +57 -4
  35. varanus-0.1.0.dev1/src/varanus/client/transport/http.py +0 -37
  36. varanus-0.1.0.dev1/src/varanus/server/search/facet.py +0 -59
  37. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/error.html +0 -1
  38. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/log.html +0 -1
  39. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/metric.html +0 -1
  40. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/query.html +0 -1
  41. varanus-0.1.0.dev1/src/varanus/server/templates/site/details/request.html +0 -44
  42. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/README.md +0 -0
  43. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/__init__.py +0 -0
  44. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/client/__init__.py +0 -0
  45. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/client/loggers.py +0 -0
  46. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/client/transport/__init__.py +0 -0
  47. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/__init__.py +0 -0
  48. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/__main__.py +0 -0
  49. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/admin.py +0 -0
  50. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/apps.py +0 -0
  51. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/asgi.py +0 -0
  52. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/context_processors.py +0 -0
  53. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/management/__init__.py +0 -0
  54. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/management/commands/__init__.py +0 -0
  55. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/management/commands/migrateall.py +0 -0
  56. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/middleware.py +0 -0
  57. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/migrations/0001_initial.py +0 -0
  58. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/migrations/0002_context_node_error_node_log_node_metric_node_and_more.py +0 -0
  59. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/migrations/__init__.py +0 -0
  60. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/router.py +0 -0
  61. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/search/__init__.py +0 -0
  62. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/search/date.py +0 -0
  63. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/settings.py +0 -0
  64. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/static/css/varanus.css +0 -0
  65. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/static/js/varanus.js +0 -0
  66. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/base.html +0 -0
  67. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/dashboard.html +0 -0
  68. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/registration/login.html +0 -0
  69. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/search/daterange.html +0 -0
  70. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/templates/search/multifacet.html +0 -0
  71. {varanus-0.1.0.dev1/src/varanus/server/views → varanus-0.1.0.dev3/src/varanus/server/templatetags}/__init__.py +0 -0
  72. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/views/base.py +0 -0
  73. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/views/dashboard.py +0 -0
  74. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/src/varanus/server/wsgi.py +0 -0
  75. {varanus-0.1.0.dev1 → varanus-0.1.0.dev3}/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.dev3
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.dev3"
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",
@@ -5,4 +5,4 @@ class VaranusClient(AppConfig):
5
5
  name = "varanus.client"
6
6
 
7
7
  def ready(self):
8
- print("VaranusClient.ready")
8
+ pass
@@ -1,6 +1,5 @@
1
1
  import platform
2
2
  from importlib.metadata import distributions
3
- from typing import Union
4
3
  from urllib.parse import urlsplit
5
4
 
6
5
  from varanus.events import Context, NodeInfo
@@ -20,14 +19,14 @@ def install_query_logger(logger):
20
19
 
21
20
 
22
21
  class VaranusClient:
23
- environment: str | None
22
+ environment: str
24
23
  transport: BaseTransport
25
24
 
26
25
  request_attr: str
27
26
  logger_name: str
28
27
  tags: dict
29
28
 
30
- include_headers: Union[list, bool, None]
29
+ include_headers: list | bool | None
31
30
  exclude_headers: list | None
32
31
  sensitive_headers = set(
33
32
  [
@@ -38,9 +37,8 @@ class VaranusClient:
38
37
  )
39
38
 
40
39
  scheme_transports = {
41
- "test": "varanus.client.transport.test.TestTransport",
42
- "http": "varanus.client.transport.http.HttpTransport",
43
- "https": "varanus.client.transport.http.HttpTransport",
40
+ "http": "varanus.client.transport.http.ThreadedHttpTransport",
41
+ "https": "varanus.client.transport.http.ThreadedHttpTransport",
44
42
  "db": "varanus.client.transport.database.ModelTransport",
45
43
  }
46
44
 
@@ -49,29 +47,38 @@ class VaranusClient:
49
47
 
50
48
  def setup(
51
49
  self,
52
- dsn,
53
- request_attr="varanus",
54
- environment=None,
55
- transport_class=None,
56
- logger_name="varanus.request",
57
- tags=None,
58
- include_headers=None,
59
- exclude_headers=None,
50
+ dsn: str,
51
+ node: str | None = None,
52
+ environment: str = "",
53
+ transport_class: str | type[BaseTransport] | None = None,
54
+ request_attr: str = "varanus",
55
+ logger_name: str = "varanus.request",
56
+ tags: dict | None = None,
57
+ include_headers: list | bool | None = None,
58
+ exclude_headers: list | None = None,
60
59
  log_queries: bool | int = False,
61
- log_query_params=False,
62
- query_metrics=False,
63
- send_all=False,
64
- install=None,
60
+ log_query_params: bool = False,
61
+ query_metrics: bool | str = False,
62
+ send_all: bool = False,
63
+ install: list | None = None,
65
64
  ):
66
65
  url = urlsplit(dsn)
67
66
  self.environment = environment
67
+ self.node = node or platform.node()
68
68
  if transport_class is None:
69
- transport_class = self.scheme_transports.get(url.scheme)
70
- if transport_class is None:
69
+ if url.scheme not in self.scheme_transports:
71
70
  raise ValueError(f"No transport class found for `{url.scheme}`")
72
- if isinstance(transport_class, str):
73
- transport_class = import_string(transport_class)
74
- self.transport = transport_class(url, self.environment)
71
+ resolved_class = import_string(self.scheme_transports[url.scheme])
72
+ elif isinstance(transport_class, str):
73
+ resolved_class = import_string(transport_class)
74
+ else:
75
+ resolved_class = transport_class
76
+ if not issubclass(resolved_class, BaseTransport):
77
+ raise ValueError(
78
+ f"Transport class `{transport_class}` must be a subclass of"
79
+ "BaseTransport."
80
+ )
81
+ self.transport = resolved_class(url, self.environment, self.node)
75
82
  self.request_attr = request_attr
76
83
  self.logger_name = logger_name
77
84
  self.tags = tags or {}
@@ -98,10 +105,6 @@ class VaranusClient:
98
105
  self.send_all = send_all
99
106
  self.configured = True
100
107
  if install:
101
- if not isinstance(install, list):
102
- raise TypeError(
103
- "The varanus middleware can only be automatically installed into a list."
104
- )
105
108
  if "django.contrib.auth.middleware.AuthenticationMiddleware" in install:
106
109
  idx = install.index(
107
110
  "django.contrib.auth.middleware.AuthenticationMiddleware"
@@ -121,14 +124,14 @@ class VaranusClient:
121
124
  def ping(self):
122
125
  self.transport.ping(
123
126
  NodeInfo(
124
- name=platform.node(),
127
+ name=self.node,
125
128
  platform=platform.platform(),
126
129
  python_version=platform.python_version(),
127
130
  packages={d.name: d.version for d in distributions()},
128
131
  )
129
132
  )
130
133
 
131
- def log(self, level, message, *args, **kwargs):
134
+ def log(self, level: int, message: str, *args, **kwargs):
132
135
  if ctx := current_context.get():
133
136
  kwargs.setdefault("stacklevel", 2)
134
137
  ctx.log(level, message, *args, **kwargs)
@@ -137,7 +140,7 @@ class VaranusClient:
137
140
  if ctx := current_context.get():
138
141
  ctx.raw_exception(exception, tags=tags)
139
142
 
140
- def metric(self, name, value: float = 0.0, tags: dict | None = None):
143
+ def metric(self, name: str, value: float = 0.0, tags: dict | None = None):
141
144
  if ctx := current_context.get():
142
145
  ctx.metric(name, value, tags=tags)
143
146
 
@@ -78,11 +78,11 @@ class VaranusContext:
78
78
 
79
79
  def log(
80
80
  self,
81
- level,
82
- message,
81
+ level: int,
82
+ message: str,
83
83
  *args,
84
84
  exc_info=None,
85
- stacklevel=1,
85
+ stacklevel: int = 1,
86
86
  tags: dict | None = None,
87
87
  **kwargs,
88
88
  ):
@@ -99,27 +99,27 @@ class VaranusContext:
99
99
  )
100
100
  )
101
101
 
102
- def debug(self, message, *args, **kwargs):
102
+ def debug(self, message: str, *args, **kwargs):
103
103
  kwargs.setdefault("stacklevel", 2)
104
104
  self.log(logging.DEBUG, message, *args, **kwargs)
105
105
 
106
- def info(self, message, *args, **kwargs):
106
+ def info(self, message: str, *args, **kwargs):
107
107
  kwargs.setdefault("stacklevel", 2)
108
108
  self.log(logging.INFO, message, *args, **kwargs)
109
109
 
110
- def warning(self, message, *args, **kwargs):
110
+ def warning(self, message: str, *args, **kwargs):
111
111
  kwargs.setdefault("stacklevel", 2)
112
112
  self.log(logging.WARNING, message, *args, **kwargs)
113
113
 
114
- def error(self, message, *args, **kwargs):
114
+ def error(self, message: str, *args, **kwargs):
115
115
  kwargs.setdefault("stacklevel", 2)
116
116
  self.log(logging.ERROR, message, *args, **kwargs)
117
117
 
118
- def critical(self, message, *args, **kwargs):
118
+ def critical(self, message: str, *args, **kwargs):
119
119
  kwargs.setdefault("stacklevel", 2)
120
120
  self.log(logging.CRITICAL, message, *args, **kwargs)
121
121
 
122
- def exception(self, message, *args, **kwargs):
122
+ def exception(self, message: str, *args, **kwargs):
123
123
  kwargs.setdefault("stacklevel", 2)
124
124
  kwargs.setdefault("exc_info", sys.exc_info())
125
125
  self.log(logging.ERROR, message, *args, **kwargs)
@@ -128,12 +128,12 @@ class VaranusContext:
128
128
  if err := Error.from_exception(exception, tags=tags):
129
129
  self.errors.append(err)
130
130
 
131
- def context(self, name="", tags: dict | None = None):
131
+ def context(self, name: str = "", tags: dict | None = None):
132
132
  ctx = VaranusContext(self.client, name, tags)
133
133
  self.subcontexts.append(ctx)
134
134
  return ctx
135
135
 
136
- def metric(self, name, value: float = 0.0, tags: dict | None = None):
136
+ def metric(self, name: str, value: float = 0.0, tags: dict | None = None):
137
137
  if name not in self.metrics:
138
138
  self.metrics[name] = Metric(name=name, tags=tags or {})
139
139
  self.metrics[name].update(value)
@@ -1,4 +1,4 @@
1
- import re
1
+ import ipaddress
2
2
 
3
3
  from django.core.exceptions import MiddlewareNotUsed
4
4
  from django.http import HttpRequest, HttpResponse
@@ -6,8 +6,6 @@ from django.http import HttpRequest, HttpResponse
6
6
  from ..events import Request
7
7
  from .client import client
8
8
 
9
- IP_REGEX = re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")
10
-
11
9
 
12
10
  def get_ip(request: HttpRequest):
13
11
  ip_address = request.META.get("HTTP_X_FORWARDED_FOR", "").strip()
@@ -15,9 +13,11 @@ def get_ip(request: HttpRequest):
15
13
  ip_address = ip_address.split(",")[0].strip()
16
14
  if not ip_address:
17
15
  ip_address = request.META.get("REMOTE_ADDR", "127.0.0.1").strip()
18
- if not IP_REGEX.match(ip_address):
19
- ip_address = None
20
- return ip_address
16
+ try:
17
+ # Validate and normalize the IP address.
18
+ return str(ipaddress.ip_address(ip_address))
19
+ except ValueError:
20
+ return ""
21
21
 
22
22
 
23
23
  def request_headers(request: HttpRequest):
@@ -4,7 +4,7 @@ from varanus import events
4
4
 
5
5
 
6
6
  class BaseTransport:
7
- def __init__(self, url: SplitResult, environment: str):
7
+ def __init__(self, url: SplitResult, environment: str, node: str):
8
8
  pass
9
9
 
10
10
  def ping(self, info: events.NodeInfo):
@@ -10,8 +10,9 @@ from .base import BaseTransport
10
10
 
11
11
 
12
12
  class ModelTransport(BaseTransport):
13
- def __init__(self, url: SplitResult, environment: str):
13
+ def __init__(self, url: SplitResult, environment: str, node: str):
14
14
  self.environment = environment
15
+ self.node = node
15
16
 
16
17
  def ping(self, info: events.NodeInfo):
17
18
  site = models.Site.objects.get()
@@ -20,9 +21,11 @@ class ModelTransport(BaseTransport):
20
21
  @transaction.atomic
21
22
  def send(self, event: events.Context):
22
23
  site = models.Site.objects.get()
24
+ node = site.nodes.get(name=self.node, environment=self.environment)
23
25
  models.Context.from_event(
24
26
  event,
25
27
  event_id=uuid.uuid4(),
26
28
  site=site,
27
29
  environment=self.environment,
30
+ node=node,
28
31
  )
@@ -0,0 +1,83 @@
1
+ import logging
2
+ import queue
3
+ import threading
4
+ import time
5
+ from typing import Any
6
+ from urllib.parse import SplitResult, parse_qs
7
+
8
+ import httpx
9
+ import msgspec
10
+
11
+ from varanus import events
12
+
13
+ from .base import BaseTransport
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class HttpTransport(BaseTransport):
19
+ def __init__(self, url: SplitResult, environment: str, node: str):
20
+ path = url.path.rstrip("/")
21
+ self.ping_url = f"{url.scheme}://{url.netloc}{path}/api/ping/"
22
+ self.event_url = f"{url.scheme}://{url.netloc}{path}/api/ingest/"
23
+ self.options = parse_qs(url.query, keep_blank_values=True)
24
+ timeout = 1.0
25
+ if "timeout" in self.options:
26
+ timeout = float(self.options["timeout"][0])
27
+ self.client = httpx.Client(
28
+ headers={
29
+ "X-Varanus-Key": url.username or "",
30
+ "X-Varanus-Environment": environment,
31
+ "X-Varanus-Node": node,
32
+ },
33
+ timeout=timeout,
34
+ )
35
+
36
+ def request(self, url: str, obj: Any):
37
+ try:
38
+ self.client.post(url, content=msgspec.json.encode(obj))
39
+ except Exception:
40
+ logger.exception("error sending to %s", url)
41
+
42
+ def ping(self, info: events.NodeInfo):
43
+ self.request(self.ping_url, info)
44
+
45
+ def send(self, event: events.Context):
46
+ self.request(self.event_url, [event])
47
+
48
+
49
+ def sender(pending: queue.SimpleQueue, client: httpx.Client, url: str, rate: float):
50
+ while True:
51
+ start = time.time()
52
+ events = []
53
+ while True:
54
+ try:
55
+ events.append(pending.get_nowait())
56
+ except queue.Empty:
57
+ break
58
+ if len(events) >= 100:
59
+ break
60
+ if events:
61
+ try:
62
+ client.post(url, content=msgspec.json.encode(events))
63
+ except Exception:
64
+ logger.exception("error sending to %s", url)
65
+ elapsed = time.time() - start
66
+ time.sleep(max(rate - elapsed, 1.0))
67
+
68
+
69
+ class ThreadedHttpTransport(HttpTransport):
70
+ def __init__(self, url: SplitResult, environment: str, node: str):
71
+ super().__init__(url, environment, node)
72
+ self.pending = queue.SimpleQueue()
73
+ rate = 3.0
74
+ if "rate" in self.options:
75
+ rate = float(self.options["rate"][0])
76
+ threading.Thread(
77
+ target=sender,
78
+ args=(self.pending, self.client, self.event_url, rate),
79
+ daemon=True,
80
+ ).start()
81
+
82
+ def send(self, event: events.Context):
83
+ self.pending.put(event)
@@ -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,21 +27,31 @@ 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
- # print("__set_name__", owner, name)
38
49
  assert issubclass(owner, Search)
39
50
  self.name = name
40
51
  self.prefix = f"{name}_"
41
52
  self.id = f"id_{name}"
42
53
  if not self.label:
43
- self.label = name.capitalize()
54
+ self.label = name.capitalize().replace("__", " ")
44
55
  if not self.field_name:
45
56
  self.field_name = name
46
57
  if not hasattr(owner, "_fields"):
@@ -48,7 +59,6 @@ class SearchField:
48
59
  owner._fields.append(self)
49
60
 
50
61
  def __get__(self, instance: "Search", owner=None) -> StringValues | Self:
51
- # print("__get__", instance, owner)
52
62
  if owner is None:
53
63
  return self
54
64
  return {
@@ -58,7 +68,7 @@ class SearchField:
58
68
  }
59
69
 
60
70
  def __set__(self, instance, value):
61
- print("__set__", instance, value)
71
+ raise AttributeError(f"`{self.name}` is read-only")
62
72
 
63
73
  def apply(self, queryset: QuerySet, field_data: StringValues) -> QuerySet:
64
74
  raise NotImplementedError()
@@ -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>