Sparklines PoC

See #271
This commit is contained in:
Klaas van Schelven
2025-11-17 10:34:57 +01:00
parent eeac2e750c
commit 60de54a3dc
4 changed files with 203 additions and 2 deletions

64
events/sparklines.py Normal file
View File

@@ -0,0 +1,64 @@
import math
from events.models import Event
def _last_digest_order_before(moment, qs_base):
row = qs_base.filter(digested_at__lt=moment).order_by('-digested_at').only('digest_order').first()
return row.digest_order if row else 0
def get_event_sparkline_indexscan(start, end, interval, **filters):
qs_base = Event.objects.filter(**filters)
bucket_edges = []
curr = start
while curr <= end:
bucket_edges.append(curr)
curr += interval
digest_orders = [
_last_digest_order_before(edge, qs_base)
for edge in bucket_edges
]
buckets = []
for i in range(1, len(bucket_edges)):
count = digest_orders[i] - digest_orders[i - 1]
buckets.append({'bucket_start': bucket_edges[i - 1], 'count': count})
return buckets
def get_x_labels(start, end, num_labels=5):
total_seconds = (end - start).total_seconds()
step_seconds = total_seconds / (num_labels - 1)
labels = []
for i in range(num_labels):
label_time = start + i * (end - start) / (num_labels - 1)
labels.append(label_time)
return labels
def get_y_labels(max_value, num_labels=5):
if max_value == 0:
return [1, 0]
# the available number of non-zero labels
available_labels = num_labels - 1
if max_value <= available_labels:
return reversed(list(range(0, max_value + 1)))
step = max_value / available_labels
# convert step into a round number:
magnitude = 10 ** (len(str(math.ceil(step))) - 1)
step = math.ceil(step / magnitude) * magnitude
labels = [0]
for i in range(1, num_labels):
labels.append(labels[-1] + step)
return reversed(labels)

View File

@@ -20,8 +20,99 @@
{# NOTE if we store event.grouper on the event, we could also show that here #}
<h1 id="key-info" class="text-2xl font-bold mt-4">Key info</h1>
<div class="mt-8 grid"
style="grid-template-columns: 2rem 1fr; grid-template-rows: auto auto; column-gap: 0.5rem;">
{# y-axis #}
<div class="flex flex-col justify-between h-24 {# h matches chart height #} mr-2 text-xs text-slate-700 dark:text-slate-200">
{# basic idea: first and last are half-cells, middle are full-width; this way the outermost labels are aligned with the outsides #}
{% for y_label in y_labels %}
{% if forloop.first %}
{#-- top half-cell #}
<div class="flex-[0.5] flex items-start justify-end pr-1 relative">
<span class="-translate-y-[35%]">
{{ y_label }}
</span>
</div>
{% elif forloop.last %}
{# bottom half-cell #}
<div class="flex-[0.5] flex items-end justify-end pr-1 relative">
<span class="translate-y-[35%]">
{{ y_label }}
</span>
</div>
{% else %}
{# full-width middle cells #}
<div class="flex-1 flex items-center justify-end pr-1">
{{ y_label }}
</div>
{% endif %}
{% endfor %}
</div>
{# Chart area #}
<div class="row-span-1">
{# bars #}
<div class="grid h-24"
style="grid-template-columns: repeat({{ bar_data|length }}, minmax(0,1fr)); gap: 2px;">
{% for pct in bar_data %}
<div class="flex items-end">
<div class="bg-slate-400 dark:bg-slate-400 w-full"
style="height: {{ pct }}%;">
</div>
</div>
{% endfor %}
</div>
{# baseline directly under bars #}
<div class="h-[1px] border-b border-slate-300 dark:border-slate-500"></div>
{# x-axis #}
<div class="mt-1 flex text-xs">
{# basic idea: as the y-axis, but without the float-out #}
{% for x_label in x_labels %}
{% if forloop.first %}
<!-- Left half-cell -->
<div class="flex-[0.5] flex justify-start">
<span class="{#-ml-2 would float-out; ugly though #}">
{{ x_label|date:"j M" }}
</span>
</div>
{% elif forloop.last %}
<!-- Right half-cell -->
<div class="flex-[0.5] flex justify-end">
<span class="{#-mr-2 would float-out; ugly though #}text-right">
{{ x_label|date:"j M" }}
</span>
</div>
{% else %}
<!-- Full-width middle cells -->
<div class="flex-1 flex justify-center">
{{ x_label|date:"j M" }}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{# bottom-left stays empty #}
<div></div>
</div>
<h1 id="key-info" class="text-2xl font-bold mt-4">Key info</h1>
<div class="mb-6">
{% for key, value in key_info %}
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">

View File

@@ -1,8 +1,10 @@
import math
from collections import namedtuple
import json
import sentry_sdk
import logging
from datetime import timedelta
from django.db.models import Q
from django.utils import timezone
from django.shortcuts import render, get_object_or_404, redirect
@@ -36,6 +38,7 @@ from .models import Issue, IssueQuerysetStateManager, IssueStateManager, Turning
from .forms import CommentForm
from .utils import get_values, get_main_exception
from events.utils import annotate_with_meta, apply_sourcemaps, get_sourcemap_images
from events.sparklines import get_event_sparkline_indexscan, get_x_labels, get_y_labels
logger = logging.getLogger("bugsink.issues")
@@ -670,6 +673,17 @@ def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=No
# sourcemaps are still experimental; we don't want to fail on them, so we just log the error and move on.
capture_or_log_exception(e, logger)
start, end, interval = get_buckets_range_input()
x_labels = get_x_labels(start, end)
buckets = get_buckets(start, end, interval, issue.id)
max_value = max(buckets) or 0
if max_value == 0:
bar_data = [0 for v in buckets]
else:
bar_data = [(v / max_value) * 100 for v in buckets]
y_labels = get_y_labels(max_value, 4)
return render(request, "issues/event_details.html", {
"tab": "event-details",
"this_view": "event_details",
@@ -690,9 +704,41 @@ def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=No
"event_qs_count": _event_count(request, issue, event_x_qs) if request.GET.get("q") else None,
"has_prev": event.digest_order > first_do,
"has_next": event.digest_order < last_do,
"bar_data": bar_data,
"y_labels": y_labels,
"x_labels": x_labels,
})
def get_buckets_range_input():
# align on 4-hour boundary; round up from now
now = timezone.localtime()
hour_step = 4
# determine how many hours to add to get to the next 4-hour boundary
fraction = ((now.hour + now.minute / 60 + now.second / 3600) / (24 // hour_step))
boundary = math.ceil(fraction) * (24 // hour_step)
end = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(hours=boundary)
# today_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
start = end - timedelta(days=28)
interval = timedelta(hours=hour_step)
return start, end, interval
def get_buckets(start, end, interval, issue_id):
data = get_event_sparkline_indexscan(
start=start,
end=end,
interval=interval,
issue_id=issue_id,
)
return [row["count"] for row in data]
@atomic_for_request_method
@issue_membership_required
def issue_history(request, issue):

File diff suppressed because one or more lines are too long