mirror of
https://github.com/bugsink/bugsink.git
synced 2025-12-21 13:00:13 -06:00
64
events/sparklines.py
Normal file
64
events/sparklines.py
Normal 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)
|
||||
@@ -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 %}">
|
||||
|
||||
@@ -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):
|
||||
|
||||
2
theme/static/css/dist/styles.css
vendored
2
theme/static/css/dist/styles.css
vendored
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user