varanus 0.1.0.dev6__tar.gz → 0.1.1__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 (113) hide show
  1. varanus-0.1.1/PKG-INFO +46 -0
  2. varanus-0.1.1/README.md +25 -0
  3. {varanus-0.1.0.dev6 → varanus-0.1.1}/pyproject.toml +11 -10
  4. varanus-0.1.1/src/varanus/.DS_Store +0 -0
  5. varanus-0.1.1/src/varanus/__init__.py +6 -0
  6. varanus-0.1.1/src/varanus/client/.DS_Store +0 -0
  7. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/__init__.py +1 -0
  8. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/client.py +15 -1
  9. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/context.py +22 -0
  10. varanus-0.1.1/src/varanus/client/transport/.DS_Store +0 -0
  11. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/transport/database.py +2 -0
  12. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/transport/http.py +2 -2
  13. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/events.py +3 -2
  14. varanus-0.1.1/src/varanus/search/.DS_Store +0 -0
  15. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/search/fields.py +3 -2
  16. varanus-0.1.1/src/varanus/search/templates/search/.DS_Store +0 -0
  17. varanus-0.1.1/src/varanus/server/.DS_Store +0 -0
  18. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/admin.py +34 -4
  19. varanus-0.1.1/src/varanus/server/histogram.py +149 -0
  20. varanus-0.1.1/src/varanus/server/management/commands/.DS_Store +0 -0
  21. varanus-0.1.1/src/varanus/server/management/commands/maintenance.py +21 -0
  22. varanus-0.1.1/src/varanus/server/migrations/0002_drop_scheduledtask.py +13 -0
  23. varanus-0.1.1/src/varanus/server/migrations/0003_site_retention.py +21 -0
  24. varanus-0.1.1/src/varanus/server/migrations/0004_node_version.py +17 -0
  25. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/models.py +17 -1
  26. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/settings.py +26 -7
  27. varanus-0.1.1/src/varanus/server/static/.DS_Store +0 -0
  28. varanus-0.1.1/src/varanus/server/static/css/.DS_Store +0 -0
  29. varanus-0.1.1/src/varanus/server/static/css/varanus.css +30 -0
  30. varanus-0.1.1/src/varanus/server/static/js/.DS_Store +0 -0
  31. varanus-0.1.1/src/varanus/server/static/js/varanus.js +85 -0
  32. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/tasks.py +9 -0
  33. varanus-0.1.1/src/varanus/server/templates/.DS_Store +0 -0
  34. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/dashboard.html +2 -0
  35. varanus-0.1.1/src/varanus/server/templates/site/.DS_Store +0 -0
  36. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/base.html +12 -6
  37. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/metric.html +3 -3
  38. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/node_env.html +1 -1
  39. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/node_settings.html +1 -1
  40. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/request.html +4 -0
  41. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/errors.html +1 -0
  42. varanus-0.1.1/src/varanus/server/templates/site/histogram.html +23 -0
  43. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/logs.html +1 -0
  44. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/metrics.html +4 -3
  45. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/overview.html +13 -3
  46. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/queries.html +1 -0
  47. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/requests.html +1 -0
  48. varanus-0.1.1/src/varanus/server/templatetags/varanus.py +50 -0
  49. varanus-0.1.1/src/varanus/server/views/.DS_Store +0 -0
  50. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/views/site.py +8 -0
  51. varanus-0.1.0.dev6/PKG-INFO +0 -19
  52. varanus-0.1.0.dev6/README.md +0 -3
  53. varanus-0.1.0.dev6/src/varanus/server/management/commands/serve.py +0 -64
  54. varanus-0.1.0.dev6/src/varanus/server/static/css/varanus.css +0 -4
  55. varanus-0.1.0.dev6/src/varanus/server/static/js/varanus.js +0 -0
  56. varanus-0.1.0.dev6/src/varanus/server/templatetags/varanus.py +0 -20
  57. varanus-0.1.0.dev6/src/varanus/tasks/__init__.py +0 -0
  58. varanus-0.1.0.dev6/src/varanus/tasks/admin.py +0 -15
  59. varanus-0.1.0.dev6/src/varanus/tasks/apps.py +0 -0
  60. varanus-0.1.0.dev6/src/varanus/tasks/backend.py +0 -62
  61. varanus-0.1.0.dev6/src/varanus/tasks/management/__init__.py +0 -0
  62. varanus-0.1.0.dev6/src/varanus/tasks/management/commands/__init__.py +0 -0
  63. varanus-0.1.0.dev6/src/varanus/tasks/management/commands/tasker.py +0 -20
  64. varanus-0.1.0.dev6/src/varanus/tasks/migrations/0001_initial.py +0 -66
  65. varanus-0.1.0.dev6/src/varanus/tasks/migrations/__init__.py +0 -0
  66. varanus-0.1.0.dev6/src/varanus/tasks/models.py +0 -131
  67. varanus-0.1.0.dev6/src/varanus/tasks/runner.py +0 -107
  68. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/apps.py +0 -0
  69. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/loggers.py +0 -0
  70. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/middleware.py +0 -0
  71. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/transport/__init__.py +0 -0
  72. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/client/transport/base.py +0 -0
  73. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/search/__init__.py +0 -0
  74. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/search/base.py +0 -0
  75. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/search/templates/search/daterange.html +0 -0
  76. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/search/templates/search/filter.html +0 -0
  77. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/search/templates/search/hidden.html +0 -0
  78. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/search/templates/search/multifacet.html +0 -0
  79. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/search/templates/search/search.html +0 -0
  80. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/search/utils.py +0 -0
  81. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/__init__.py +0 -0
  82. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/__main__.py +0 -0
  83. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/apps.py +0 -0
  84. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/asgi.py +0 -0
  85. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/context_processors.py +0 -0
  86. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/integrations/__init__.py +0 -0
  87. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/integrations/base.py +0 -0
  88. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/integrations/squish.py +0 -0
  89. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/management/__init__.py +0 -0
  90. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/management/commands/__init__.py +0 -0
  91. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/management/commands/migrateall.py +0 -0
  92. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/middleware.py +0 -0
  93. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/migrations/0001_initial.py +0 -0
  94. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/migrations/__init__.py +0 -0
  95. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/router.py +0 -0
  96. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/base.html +0 -0
  97. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/registration/login.html +0 -0
  98. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/environment_nodes.html +0 -0
  99. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/error.html +0 -0
  100. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/log.html +0 -0
  101. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/node_environments.html +0 -0
  102. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/node_packages.html +0 -0
  103. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templates/site/details/query.html +0 -0
  104. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/templatetags/__init__.py +0 -0
  105. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/urls.py +0 -0
  106. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/utils.py +0 -0
  107. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/views/__init__.py +0 -0
  108. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/views/api.py +0 -0
  109. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/views/base.py +0 -0
  110. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/views/dashboard.py +0 -0
  111. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/server/wsgi.py +0 -0
  112. {varanus-0.1.0.dev6 → varanus-0.1.1}/src/varanus/utils.py +0 -0
  113. /varanus-0.1.0.dev6/src/varanus/__init__.py → /varanus-0.1.1/src/varanus/version.py +0 -0
varanus-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.3
2
+ Name: varanus
3
+ Version: 0.1.1
4
+ Summary: Django application monitoring.
5
+ Author: Dan Watson
6
+ Author-email: Dan Watson <watsond@imsweb.com>
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Dist: httpx>=0.27.0
11
+ Requires-Dist: msgspec>=0.19.0
12
+ Requires-Dist: cconf>=1.0.0 ; extra == 'server'
13
+ Requires-Dist: django>=6.0 ; python_full_version >= '3.12' and extra == 'server'
14
+ Requires-Dist: django-dbtasks[serve]>=0.4.0 ; python_full_version >= '3.12' and extra == 'server'
15
+ Requires-Dist: django-passkey-auth>=0.2.0 ; extra == 'server'
16
+ Requires-Dist: psycopg[binary]>=3.2.1 ; extra == 'server'
17
+ Requires-Dist: whitenoise>=6.7.0 ; extra == 'server'
18
+ Requires-Python: >=3.11
19
+ Provides-Extra: server
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Varanus
23
+
24
+ ## Installation
25
+
26
+ ```
27
+ pip install varanus
28
+ ```
29
+
30
+ ## Quickstart for Django
31
+
32
+ In your `settings.py`:
33
+
34
+ ```python
35
+ try:
36
+ import varanus.client
37
+ varanus.client.setup(
38
+ "https://APIKEY@varanus.example.com",
39
+ environment=os.getenv("VARANUS_ENV", "local"),
40
+ install=MIDDLEWARE,
41
+ )
42
+ except ImportError:
43
+ pass
44
+ ```
45
+
46
+ For more information about how to configure the Varanus client, see [the configuration docs](docs/configuration.md). If nothing else, note that when using uWSGI, you'll need to enable threads with the [enable-threads](https://uwsgi-docs.readthedocs.io/en/latest/Options.html#enable-threads) option.
@@ -0,0 +1,25 @@
1
+ # Varanus
2
+
3
+ ## Installation
4
+
5
+ ```
6
+ pip install varanus
7
+ ```
8
+
9
+ ## Quickstart for Django
10
+
11
+ In your `settings.py`:
12
+
13
+ ```python
14
+ try:
15
+ import varanus.client
16
+ varanus.client.setup(
17
+ "https://APIKEY@varanus.example.com",
18
+ environment=os.getenv("VARANUS_ENV", "local"),
19
+ install=MIDDLEWARE,
20
+ )
21
+ except ImportError:
22
+ pass
23
+ ```
24
+
25
+ For more information about how to configure the Varanus client, see [the configuration docs](docs/configuration.md). If nothing else, note that when using uWSGI, you'll need to enable threads with the [enable-threads](https://uwsgi-docs.readthedocs.io/en/latest/Options.html#enable-threads) option.
@@ -1,20 +1,28 @@
1
1
  [project]
2
2
  name = "varanus"
3
- version = "0.1.0.dev6"
3
+ version = "0.1.1"
4
4
  description = "Django application monitoring."
5
+ authors = [
6
+ { name = "Dan Watson", email = "watsond@imsweb.com" }
7
+ ]
5
8
  readme = "README.md"
6
9
  requires-python = ">=3.11"
7
10
  dependencies = [
8
11
  "httpx>=0.27.0",
9
12
  "msgspec>=0.19.0",
10
13
  ]
14
+ classifiers = [
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python",
17
+ "Programming Language :: Python :: 3",
18
+ ]
11
19
 
12
20
  [project.optional-dependencies]
13
21
  server = [
14
22
  "cconf>=1.0.0",
15
23
  "django>=6.0; python_version>='3.12'",
24
+ "django-dbtasks[serve]>=0.4.0; python_version>='3.12'",
16
25
  "django-passkey-auth>=0.2.0",
17
- "granian[reload]>=2.4.2",
18
26
  "psycopg[binary]>=3.2.1",
19
27
  "whitenoise>=6.7.0",
20
28
  ]
@@ -22,17 +30,10 @@ server = [
22
30
  [dependency-groups]
23
31
  dev = []
24
32
 
25
- [project.scripts]
26
- manage = "varanus.server.__main__:manage"
27
-
28
33
  [build-system]
29
- requires = ["uv_build>=0.9.2,<0.10.0"]
34
+ requires = ["uv_build>=0.11.0,<0.12.0"]
30
35
  build-backend = "uv_build"
31
36
 
32
37
  [tool.ruff.lint]
33
38
  extend-select = ["I"]
34
39
  isort.known-first-party = ["varanus"]
35
-
36
- [tool.pytest.ini_options]
37
- addopts = "--tb=short -s"
38
- DJANGO_SETTINGS_MODULE = "varanus.server.settings"
Binary file
@@ -0,0 +1,6 @@
1
+ from .version import __version__, __version_info__
2
+
3
+ __all__ = [
4
+ "__version__",
5
+ "__version_info__",
6
+ ]
@@ -4,3 +4,4 @@ setup = client.setup
4
4
  context = client.context
5
5
  log = client.log
6
6
  metric = client.metric
7
+ timer = client.timer
@@ -1,13 +1,16 @@
1
+ import logging
1
2
  import os
2
3
  import platform
4
+ import warnings
3
5
  from importlib.metadata import distributions
4
6
  from typing import Iterable
5
7
  from urllib.parse import urlsplit
6
8
 
9
+ import varanus
7
10
  from varanus.events import Context, NodeInfo
8
11
 
9
12
  from ..utils import import_string
10
- from .context import VaranusContext, current_context
13
+ from .context import LoggingTimer, VaranusContext, current_context
11
14
  from .loggers import QueryLogger
12
15
  from .transport.base import BaseTransport
13
16
 
@@ -61,6 +64,7 @@ class VaranusClient:
61
64
  transport_class: str | type[BaseTransport] | None = None,
62
65
  request_attr: str = "varanus",
63
66
  logger_name: str = "varanus.request",
67
+ log_warnings: bool = True,
64
68
  tags: dict | None = None,
65
69
  include_headers: Iterable[str] | bool = False,
66
70
  exclude_headers: Iterable[str] | None = None,
@@ -133,6 +137,9 @@ class VaranusClient:
133
137
  except ImportError:
134
138
  pass
135
139
  self.send_all = send_all
140
+ if log_warnings:
141
+ warnings.simplefilter("default")
142
+ logging.captureWarnings(True)
136
143
  self.configured = True
137
144
  if install:
138
145
  if "django.contrib.auth.middleware.AuthenticationMiddleware" in install:
@@ -181,6 +188,7 @@ class VaranusClient:
181
188
  self.transport.ping(
182
189
  NodeInfo(
183
190
  name=self.node,
191
+ version=varanus.__version__,
184
192
  platform=platform.platform(),
185
193
  language=platform.python_implementation(),
186
194
  language_version=platform.python_version(),
@@ -209,6 +217,12 @@ class VaranusClient:
209
217
  if ctx := current_context.get():
210
218
  ctx.metric(name, value, tags=tags)
211
219
 
220
+ def timer(self, name: str, tags: dict | None = None):
221
+ if ctx := current_context.get():
222
+ return ctx.timer(name, tags=tags)
223
+ else:
224
+ return LoggingTimer(name, tags=tags)
225
+
212
226
  def context(self, name: str, tags: dict | None = None):
213
227
  if ctx := current_context.get():
214
228
  return ctx.context(name, tags)
@@ -1,6 +1,8 @@
1
+ import contextlib
1
2
  import inspect
2
3
  import logging
3
4
  import sys
5
+ import time
4
6
  from contextvars import ContextVar, Token
5
7
  from datetime import timedelta
6
8
  from typing import TYPE_CHECKING
@@ -11,6 +13,7 @@ if TYPE_CHECKING:
11
13
  from .client import VaranusClient
12
14
 
13
15
  ONE_MS = timedelta(milliseconds=1)
16
+ logger = logging.getLogger(__name__)
14
17
 
15
18
 
16
19
  class VaranusContext:
@@ -138,8 +141,27 @@ class VaranusContext:
138
141
  self.metrics[name] = Metric(name=name, tags=tags or {})
139
142
  self.metrics[name].update(value)
140
143
 
144
+ @contextlib.contextmanager
145
+ def timer(self, name: str, tags: dict | None = None):
146
+ start = time.monotonic()
147
+ try:
148
+ yield self
149
+ finally:
150
+ elapsed = time.monotonic() - start
151
+ self.metric(name, elapsed, tags=tags)
152
+
141
153
 
142
154
  current_context: ContextVar[VaranusContext | None] = ContextVar(
143
155
  "current_context",
144
156
  default=None,
145
157
  )
158
+
159
+
160
+ @contextlib.contextmanager
161
+ def LoggingTimer(name: str, tags: dict | None = None):
162
+ start = time.monotonic()
163
+ try:
164
+ yield None
165
+ finally:
166
+ elapsed_ms = int((time.monotonic() - start) * 1000.0)
167
+ logger.debug("Timer %s (tags=%s) finished in %d ms", name, tags, elapsed_ms)
@@ -47,6 +47,8 @@ class ModelTransport(BaseTransport):
47
47
  def send(self, event: events.Context):
48
48
  from varanus.server.tasks import ingest
49
49
 
50
+ self.ensure_site()
51
+
50
52
  ingest.enqueue(
51
53
  self.site.pk,
52
54
  self.node_name,
@@ -48,7 +48,7 @@ class HttpTransport(BaseTransport):
48
48
 
49
49
  def sender(pending: queue.SimpleQueue, client: httpx.Client, url: str, rate: float):
50
50
  while True:
51
- start = time.time()
51
+ start = time.monotonic()
52
52
  events = []
53
53
  while True:
54
54
  try:
@@ -62,7 +62,7 @@ def sender(pending: queue.SimpleQueue, client: httpx.Client, url: str, rate: flo
62
62
  client.post(url, content=msgspec.json.encode(events))
63
63
  except Exception:
64
64
  logger.exception("error sending to %s", url)
65
- elapsed = time.time() - start
65
+ elapsed = time.monotonic() - start
66
66
  time.sleep(max(rate - elapsed, 1.0))
67
67
 
68
68
 
@@ -26,6 +26,7 @@ class NodeInfo(Struct):
26
26
  packages: dict[str, str]
27
27
  settings: dict[str, str]
28
28
  environment: dict[str, str]
29
+ version: str = ""
29
30
 
30
31
 
31
32
  class StackLine(Struct):
@@ -153,8 +154,8 @@ class Metric(Event):
153
154
  agg_count: int = 0
154
155
  agg_sum: float = 0.0
155
156
  agg_avg: float = 0.0
156
- agg_min: float = 0.0
157
- agg_max: float = 0.0
157
+ agg_min: float = float("inf")
158
+ agg_max: float = float("-inf")
158
159
 
159
160
  def update(self, value: float):
160
161
  self.agg_count += 1
@@ -104,8 +104,9 @@ class DateRange(SearchField):
104
104
  today = datetime.date.today()
105
105
  buttons = [
106
106
  (_("Today"), today - datetime.timedelta(days=0)),
107
- (_("Last 7 Days"), today - datetime.timedelta(days=7)),
108
- (_("Last 30 Days"), today - datetime.timedelta(days=30)),
107
+ (_("7 Days"), today - datetime.timedelta(days=7)),
108
+ (_("30 Days"), today - datetime.timedelta(days=30)),
109
+ (_("Year"), today - datetime.timedelta(days=365)),
109
110
  ]
110
111
  return {
111
112
  **super().get_context(queryset, field_data, request=request),
@@ -20,28 +20,47 @@ from .models import (
20
20
  )
21
21
 
22
22
 
23
+ class SiteAuthMixIn:
24
+ """
25
+ Allows anyone with access to the Site object (in SiteAdmin.get_queryset) full admin
26
+ capabilities.
27
+ """
28
+
29
+ def has_view_permission(self, request, obj=None):
30
+ return True
31
+
32
+ def has_add_permission(self, request, obj=None):
33
+ return True
34
+
35
+ def has_change_permission(self, request, obj=None):
36
+ return True
37
+
38
+ def has_delete_permission(self, request, obj=None):
39
+ return True
40
+
41
+
23
42
  class SaveDefaultModelForm(forms.ModelForm):
24
43
  def has_changed(self):
25
44
  return self.instance._state.adding or super().has_changed()
26
45
 
27
46
 
28
- class SiteMemberInline(admin.TabularInline):
47
+ class SiteMemberInline(SiteAuthMixIn, admin.TabularInline):
29
48
  model = SiteMember
30
49
  extra = 0
31
50
 
32
51
 
33
- class SiteKeyInline(admin.TabularInline):
52
+ class SiteKeyInline(SiteAuthMixIn, admin.TabularInline):
34
53
  model = SiteKey
35
54
  form = SaveDefaultModelForm
36
55
  extra = 0
37
56
 
38
57
 
39
- class SiteIntegrationInline(admin.StackedInline):
58
+ class SiteIntegrationInline(SiteAuthMixIn, admin.StackedInline):
40
59
  model = SiteIntegration
41
60
  extra = 0
42
61
 
43
62
 
44
- class SiteAdmin(admin.ModelAdmin):
63
+ class SiteAdmin(SiteAuthMixIn, admin.ModelAdmin):
45
64
  list_display = ["name", "slug", "schema_name"]
46
65
  inlines = [SiteMemberInline, SiteKeyInline, SiteIntegrationInline]
47
66
  prepopulated_fields = {"slug": ["name"], "schema_name": ["name"]}
@@ -53,6 +72,7 @@ class SiteAdmin(admin.ModelAdmin):
53
72
  "name",
54
73
  "slug",
55
74
  "schema_name",
75
+ "retention",
56
76
  "module_filter",
57
77
  ]
58
78
  },
@@ -71,6 +91,15 @@ class SiteAdmin(admin.ModelAdmin):
71
91
  ),
72
92
  ]
73
93
 
94
+ def get_queryset(self, request):
95
+ qs = Site.objects.all()
96
+ if not request.user.is_superuser:
97
+ qs = qs.filter(members__user=request.user, members__is_admin=True)
98
+ return qs
99
+
100
+ def has_module_permission(self, request):
101
+ return True
102
+
74
103
 
75
104
  class RequestAdmin(admin.ModelAdmin):
76
105
  list_display = [
@@ -145,6 +174,7 @@ class NodeAdmin(admin.ModelAdmin):
145
174
  "name",
146
175
  "site",
147
176
  "environment",
177
+ "version",
148
178
  "platform",
149
179
  "language",
150
180
  "language_version",
@@ -0,0 +1,149 @@
1
+ import datetime
2
+ from enum import StrEnum
3
+
4
+ from django.db.models import Count, Max, Min, QuerySet
5
+ from django.db.models.functions import TruncDay, TruncHour, TruncMonth, TruncWeek
6
+ from django.utils import timezone
7
+
8
+
9
+ class Granularity(StrEnum):
10
+ HOUR = "hour"
11
+ DAY = "day"
12
+ WEEK = "week"
13
+ MONTH = "month"
14
+
15
+ @classmethod
16
+ def from_span(cls, span: datetime.timedelta) -> "Granularity":
17
+ if span <= datetime.timedelta(days=4):
18
+ return cls.HOUR
19
+ if span <= datetime.timedelta(days=90):
20
+ return cls.DAY
21
+ if span <= datetime.timedelta(days=730):
22
+ return cls.WEEK
23
+ return cls.MONTH
24
+
25
+ @property
26
+ def truncator(self):
27
+ return {
28
+ Granularity.HOUR: TruncHour,
29
+ Granularity.DAY: TruncDay,
30
+ Granularity.WEEK: TruncWeek,
31
+ Granularity.MONTH: TruncMonth,
32
+ }[self]
33
+
34
+ def floor(self, value: datetime.datetime) -> datetime.datetime:
35
+ if self is Granularity.HOUR:
36
+ return value.replace(minute=0, second=0, microsecond=0)
37
+ if self is Granularity.DAY:
38
+ return value.replace(hour=0, minute=0, second=0, microsecond=0)
39
+ if self is Granularity.WEEK:
40
+ day = value.replace(hour=0, minute=0, second=0, microsecond=0)
41
+ return day - datetime.timedelta(days=day.weekday())
42
+ return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
43
+
44
+ def next(self, value: datetime.datetime) -> datetime.datetime:
45
+ if self is Granularity.HOUR:
46
+ return value + datetime.timedelta(hours=1)
47
+ if self is Granularity.DAY:
48
+ return value + datetime.timedelta(days=1)
49
+ if self is Granularity.WEEK:
50
+ return value + datetime.timedelta(days=7)
51
+ if value.month == 12:
52
+ return value.replace(year=value.year + 1, month=1, day=1)
53
+ return value.replace(month=value.month + 1, day=1)
54
+
55
+ def label(self, value: datetime.datetime) -> str:
56
+ if self is Granularity.HOUR:
57
+ return value.strftime("%Y-%m-%d %H:00")
58
+ if self is Granularity.DAY:
59
+ return value.strftime("%Y-%m-%d")
60
+ if self is Granularity.WEEK:
61
+ return f"Week of {value.strftime('%Y-%m-%d')}"
62
+ return value.strftime("%Y-%m")
63
+
64
+
65
+ class Histogram:
66
+ def __init__(
67
+ self,
68
+ queryset: QuerySet,
69
+ start_date: datetime.date | None = None,
70
+ end_date: datetime.date | None = None,
71
+ ):
72
+ self.queryset = queryset
73
+ self.start_date = start_date
74
+ self.end_date = end_date
75
+ self.tz = timezone.get_current_timezone()
76
+
77
+ def _to_localtime(self, value: datetime.datetime) -> datetime.datetime:
78
+ if timezone.is_naive(value):
79
+ value = timezone.make_aware(value, self.tz)
80
+ return timezone.localtime(value, self.tz)
81
+
82
+ def _resolve_range(self) -> tuple[datetime.datetime, datetime.datetime] | None:
83
+ start_date = self.start_date
84
+ end_date = self.end_date
85
+ if start_date and not end_date:
86
+ end_date = timezone.localdate()
87
+ if end_date and not start_date:
88
+ start_date = end_date
89
+
90
+ if start_date and end_date:
91
+ start = timezone.make_aware(
92
+ datetime.datetime.combine(start_date, datetime.time.min),
93
+ self.tz,
94
+ )
95
+ end = timezone.make_aware(
96
+ datetime.datetime.combine(end_date, datetime.time.max),
97
+ self.tz,
98
+ )
99
+ return (start, end)
100
+
101
+ agg = self.queryset.aggregate(start=Min("timestamp"), end=Max("timestamp"))
102
+ if not agg["start"] or not agg["end"]:
103
+ return None
104
+ return (self._to_localtime(agg["start"]), self._to_localtime(agg["end"]))
105
+
106
+ def as_context(self) -> dict | None:
107
+ range_values = self._resolve_range()
108
+ if not range_values:
109
+ return None
110
+ start, end = range_values
111
+
112
+ granularity = Granularity.from_span(end - start)
113
+ raw_buckets = (
114
+ self.queryset.annotate(bucket=granularity.truncator("timestamp"))
115
+ .values("bucket")
116
+ .annotate(num=Count("id"))
117
+ .order_by("bucket")
118
+ )
119
+ counts = {
120
+ self._to_localtime(item["bucket"]): item["num"] for item in raw_buckets
121
+ }
122
+
123
+ bucket_start = granularity.floor(start)
124
+ bucket_end = granularity.floor(end)
125
+ if bucket_end < bucket_start:
126
+ return None
127
+
128
+ buckets = []
129
+ cursor = bucket_start
130
+ max_count = max(counts.values()) if counts else 0
131
+ while cursor <= bucket_end:
132
+ count = counts.get(cursor, 0)
133
+ buckets.append(
134
+ {
135
+ "label": granularity.label(cursor),
136
+ "count": count,
137
+ "height_pct": (count / max_count * 100) if max_count else 0,
138
+ }
139
+ )
140
+ cursor = granularity.next(cursor)
141
+ if len(buckets) > 500:
142
+ break
143
+
144
+ return {
145
+ "buckets": buckets,
146
+ "granularity": str(granularity),
147
+ "start_label": granularity.label(bucket_start),
148
+ "end_label": granularity.label(bucket_end),
149
+ }
@@ -0,0 +1,21 @@
1
+ from django.core.management import BaseCommand
2
+
3
+ from varanus.server.tasks import maintenance
4
+
5
+
6
+ class Command(BaseCommand):
7
+ help = "Manually runs the maintenance task."
8
+
9
+ def add_arguments(self, parser):
10
+ parser.add_argument(
11
+ "-q",
12
+ "--queue",
13
+ action="store_true",
14
+ help="Queues the task instead of running it directly",
15
+ )
16
+
17
+ def handle(self, *args, **options):
18
+ if options["queue"]:
19
+ maintenance.enqueue()
20
+ else:
21
+ maintenance.call()
@@ -0,0 +1,13 @@
1
+ # Generated by Django 6.0 on 2025-12-20 21:41
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("varanus", "0001_initial"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.RunSQL("DROP TABLE IF EXISTS tasks_scheduledtask"),
13
+ ]
@@ -0,0 +1,21 @@
1
+ # Generated by Django 6.0 on 2025-12-22 17:46
2
+
3
+ from django.db import migrations, models
4
+
5
+ import varanus.server.models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ dependencies = [
10
+ ("varanus", "0002_drop_scheduledtask"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name="site",
16
+ name="retention",
17
+ field=models.CharField(
18
+ default=varanus.server.models.default_retention, max_length=20
19
+ ),
20
+ ),
21
+ ]
@@ -0,0 +1,17 @@
1
+ # Generated by Django 6.0 on 2025-12-29 18:41
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("varanus", "0003_site_retention"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AddField(
13
+ model_name="node",
14
+ name="version",
15
+ field=models.CharField(blank=True, max_length=20),
16
+ ),
17
+ ]