Skip to content

Commit 908b461

Browse files
committed
feat(admin): chart to visualize spike detector output
1 parent b1b59c9 commit 908b461

18 files changed

+282
-22
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ repos:
7878
hooks:
7979
- id: codespell
8080
args: ["-w"]
81+
exclude: "static/.*"
8182

8283
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
8384
rev: v9.0.0

controller/sentry/admin.py

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@
1818
from controller.sentry.choices import EventType, MetricType
1919
from controller.sentry.forms import BumpForm, MetricForm
2020
from controller.sentry.inlines import AppEventInline, ProjectEventInline
21-
from controller.sentry.mixins import PrettyTypeMixin, ProjectLinkMixin
21+
from controller.sentry.mixins import ChartMixin, PrettyTypeMixin, ProjectLinkMixin
2222
from controller.sentry.models import App, Event, Project
2323
from controller.sentry.utils import invalidate_cache
2424

2525

2626
@admin.register(Project)
2727
class ProjectAdmin(
28+
ChartMixin,
2829
AdminConfirmMixin,
2930
ActionFormMixin,
3031
DjangoObjectActions,
@@ -41,18 +42,76 @@ class ProjectAdmin(
4142
ordering = search_fields
4243

4344
formfield_overrides = {
44-
models.JSONField: {"widget": JSONEditorWidget},
45+
models.JSONField: {"widget": JSONEditorWidget(width="100%")},
4546
}
4647

4748
fieldsets = [
4849
[
4950
None,
50-
{"fields": ("sentry_id", "sentry_project_slug", "detection_param")},
51+
{
52+
"fields": (
53+
"sentry_id",
54+
"sentry_project_slug",
55+
("detection_param", "detection_result"),
56+
)
57+
},
5158
]
5259
]
5360

5461
inlines = [ProjectEventInline]
5562

63+
def get_chart_data(self, sentry_id):
64+
project = Project.objects.get(sentry_id=sentry_id)
65+
if project.detection_result is None:
66+
return None
67+
68+
threshold = project.detection_param["threshold"]
69+
70+
options = {
71+
"aspectRatio": 4,
72+
"scales": {
73+
"xAxis": {"type": "timeseries"},
74+
"series": {"position": "left"},
75+
"signal": {"position": "right"},
76+
},
77+
"plugins": {"legend": {"position": "bottom"}, "title": {"display": True, "text": "Detection Result"}},
78+
"elements": {"line": {"stepped": True}, "point": {"radius": 0}},
79+
"interaction": {"mode": "index", "intersect": False},
80+
}
81+
data = {
82+
"datasets": [
83+
{
84+
"label": "Series",
85+
"backgroundColor": "#36a2eb",
86+
"borderColor": "#36a2eb",
87+
"data": project.detection_result["series"],
88+
"yAxisID": "series",
89+
},
90+
{
91+
"label": "Signal",
92+
"backgroundColor": "#ff6384",
93+
"borderColor": "#ff6384",
94+
"data": project.detection_result["signal"],
95+
"yAxisID": "signal",
96+
},
97+
{
98+
"label": "Threshold",
99+
"backgroundColor": "#9966ff",
100+
"borderColor": "#9966ff",
101+
"data": [
102+
avg_filter + threshold * std_filter
103+
for avg_filter, std_filter in zip(
104+
project.detection_result["avg_filter"],
105+
project.detection_result["std_filter"],
106+
)
107+
],
108+
"yAxisID": "series",
109+
},
110+
],
111+
"labels": project.detection_result["intervals"],
112+
}
113+
return data, options
114+
56115

57116
@admin.register(Event)
58117
class EventAdmin(
@@ -71,7 +130,7 @@ class EventAdmin(
71130
ordering = search_fields
72131

73132
formfield_overrides = {
74-
models.JSONField: {"widget": JSONEditorWidget},
133+
models.JSONField: {"widget": JSONEditorWidget(width="100%")},
75134
}
76135

77136
fieldsets = [
@@ -109,7 +168,7 @@ class AppAdmin(
109168
ordering = search_fields
110169

111170
formfield_overrides = {
112-
models.JSONField: {"widget": JSONEditorWidget},
171+
models.JSONField: {"widget": JSONEditorWidget(width="100%")},
113172
}
114173

115174
fieldsets = [

controller/sentry/detector.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,17 @@ def compute_sentry(self, stats):
5656
if series is None:
5757
raise ValueError("No series with accepted outcome")
5858

59-
signal, _, _ = self.compute(series)
59+
signal, avg_filter, std_filter = self.compute(series)
6060

6161
annotated_result = OrderedDict((date, signal) for date, signal in zip(stats["intervals"], signal))
62-
return annotated_result
62+
dump = {
63+
"signal": signal,
64+
"avg_filter": avg_filter,
65+
"std_filter": std_filter,
66+
"series": series,
67+
"intervals": stats["intervals"],
68+
}
69+
return annotated_result, dump
6370

6471
def compute(self, data):
6572
signals = [0] * self.lag
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.1.6 on 2023-02-09 13:24
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("sentry", "0011_alter_event_options_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="project",
15+
name="detection_result",
16+
field=models.JSONField(blank=True, null=True),
17+
),
18+
]

controller/sentry/mixins.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,22 @@ def get_project(self, obj):
1919
return None
2020
url = reverse("admin:%s_%s_change" % (self.model._meta.app_label, "project"), args=(obj.project.pk,))
2121
return format_html('<a href="%s">%s</a>' % (url, str(obj.project)))
22+
23+
24+
class ChartMixin:
25+
change_form_template = "admin/chart_change_form.html"
26+
27+
def change_view(self, request, object_id, form_url="", extra_context=None):
28+
29+
if extra_context is None:
30+
extra_context = {}
31+
32+
if result := self.get_chart_data(object_id):
33+
dataset, options = result
34+
extra_context["adminchart_chartjs_config"] = {
35+
"type": "line",
36+
"data": dataset,
37+
"options": options,
38+
}
39+
40+
return super().change_view(request, object_id, form_url, extra_context)

controller/sentry/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class Project(models.Model):
2424
sentry_project_slug = models.CharField(max_length=50, db_index=True, null=True, blank=True)
2525

2626
detection_param = models.JSONField(default=partial(settings_default_value, "DEFAULT_SPIKE_DETECTION_PARAM"))
27+
detection_result = models.JSONField(blank=True, null=True)
2728

2829
def __str__(self) -> str:
2930
return f"Project({self.sentry_id} - {self.sentry_project_slug if self.sentry_project_slug else 'Pending'})"
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
if (document.readyState !== 'loading') {
2+
init_chart();
3+
} else {
4+
document.addEventListener('DOMContentLoaded', init_chart);
5+
}
6+
7+
function init_chart() {
8+
const configScript = document.getElementById("adminchart-chartjs-config");
9+
if (!configScript) return;
10+
const chartConfig = JSON.parse(configScript.textContent);
11+
if (!chartConfig) return;
12+
var container = document.getElementById('admincharts')
13+
var canvas = document.createElement("canvas")
14+
container.appendChild(canvas)
15+
var ctx = canvas.getContext('2d');
16+
const style = getComputedStyle(document.documentElement)
17+
const fg_color = style.getPropertyValue('--body-fg');
18+
const bg_color = style.getPropertyValue('--hairline-color');
19+
Chart.defaults.borderColor = bg_color;
20+
Chart.defaults.color = fg_color;
21+
var chart = new Chart(ctx, chartConfig);
22+
23+
}

controller/sentry/static/admin/chart/js/chart.date.min.js

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

controller/sentry/static/admin/chart/js/chart.min.js

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

controller/sentry/tasks.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,10 @@ def perform_detect(sentry_id) -> None:
8787

8888
stats = client.get_stats(project.sentry_id)
8989
detector = SpikesDetector.from_project(project)
90-
res = detector.compute_sentry(stats)
90+
res, dump = detector.compute_sentry(stats)
91+
92+
project.detection_result = dump
93+
project.save()
9194

9295
previous_signal = 0
9396
events = []

0 commit comments

Comments
 (0)