Merge branch 'main' into django-5-2

This commit is contained in:
Klaas van Schelven
2025-07-27 21:46:28 +02:00
175 changed files with 4432 additions and 894 deletions

View File

@@ -1,5 +1,130 @@
# Changes
## 1.7.3 (17 July 2025)
Migration fix: delete TurningPoints w/ project=None (Fix #155)
## 1.7.2 (17 July 2025)
Various fixes:
* Dark mode: use monokai style from pygments (Fix #152)
* add `vacuum_files` command (Fix #129)
* Artifact Bundle upload: clean up after extract (See #129)
* Add API catch-all endpoint for logging (Fix #153)
* File-upload: chunk-size of 2MiB (Fix #147)
* Sourcemaps upload: max file size 2GiB (See #147)
* Auto-clean binlogs on docker compose (sample) for mysql (See #149)
* Remove platform 'choices' from Event.model (See 403e28adb410)
* Better `ALLOWED_HOSTS` misconfig error-message (Fix #148)
* As per the "little red box on" #120
* Fix wasted space at certain width in stacktrace UI (See #120)
* Fixed command's 'running in background' output (See 770ccb16225e)
* Project-edit: redirect to list on-save (See 2b46bfe9a114)
* `cleanup_eventstorage` command: be more clear when no `event_storage` is actually configured (See b2769d7202b6)
* Don't crash on illegal values for platform (See #143, #145)
* Support 'crystal' platform (Fix #145)
* Support 'powershell' platform (Fix #143)
## 1.7.1 (10 July 2025)
Fix: user-related forms broken by unclosed link
## 1.7.0 (9 July 2025)
Bugsink 1.7.0 introduces Dark Mode (See #40, #125)
### Housekeeping
A number of options to clean up unwanted or unneeded data have been added:
* Project Deletion (See #50, #137)
* Issue Deletion (See #50)
* Vacuum Tags command (See #135)
* `vacuum_eventless_issuetags` command (see #134, #142)
How these commands/tools relate to each other and may be used is [documented on
the website](https://www.bugsink.com/docs/housekeeping/)
### Various small fixes
* Skip `ALLOWED_HOSTS` validation for /health/ endpoints (see #140)
* `get_system_warnings` as a callable (see c2bc2e417475)
* `store_tags`: support 'very many' (~500) tags (see d62e53fdf8e7)
* Snappea: refuse to start in `TASK_ALWAYS_EAGER` mode (see aa255978b776)
* Sentry-SDK requirement, unpin minor version (see a91fdcd65673)
## 1.6.3 (27 June 2025)
* fix `make_consistent` on mysql (Fix #132)
* Tags in `event_data` can be lists; deal with that (Fix #130)
## 1.6.2 (19 June 2025)
* Too many quotes in local-vars display (Fix #119)
## 1.6.1 (11 June 2025)
Remove hard-coded slack `webhook_url` from the "test this connector" loop.
## 1.6.0 (10 June 2025)
### Slack Alerts
Bugsink 1.6.0 introduces Slack Alerts (through webhooks); see #3.
### Backwards-incompatible changes
* The default number of web processes (gunicorn server workers) in the
dockerized setup is now equal to `min(cpu_count, 4)`; (it used to be 10).
set `GUNICORN_CMD_ARGS="--workers=10"` to restore the previous behavior or
choose a custom number.
### Various Features & Fixes
* Display formatted log message when available (see #111)
* Add 2 env variables to compose-sample.yaml (See #110)
* Add delete functionality for users (See #108)
* Multi-file sourcemaps (See #87)
* Lookup by `debug_id` in dicts: use UUID (See #105)
* Add robots.txt that disallows crawling
* Add HEALTHCHECK command to Dockerfiles (See #98)
* Fingerprint: convert to string before concatenating (See #102)
* Add /health/ready endpoint (See #98)
## 1.5.4 (12 May 2025)
* Add bugsink-util script to allow settings-independent commands to be run
* UX of the `stress_test` command (param cleanup)
* checks on `settings.BASE_URL`
* Show _all_ Request Headers in `CSRF_DEBUG` view (see #100)
* Fix obj not found when visiting project as a non-member superuser
## 1.5.3 (7 May 2025)
* Performance fixes of the issue-list when there are many (millions) of _issues_ (rather than just events) in the
database; see aad0f624f904 & 0dfd01db9b38.
* Fix: `different_runtime_limit` applying to the wrong DB alias, see 699f6e587d28
* `CREATE_SUPERUSER` shortcut: robust for ':' in password, see 9b0f0e04f4e4
## 1.5.2 (6 May 2025)
Various performance fixes when there are many (millions) of _issues_
(rather than just events) in the database:
* Add index for `Grouping.grouping_key` (and project), see 392f5a30be18, 49e6700d4a81
* Digest: check Grouping.exists only once (save a query)
* Remove `open_issue_count` from homepage; it's too expensive
* Issue Paginator: don't attempt to count the Issues, see 378366105496
* Stress test command: more fat-tailed randomness (d5a449020d03)
Compatibility fix:
* `format_exception` in `capture_or_log_exception`: python 3.9 compatible
## 1.5.1 (24 April 2025)
Various fixes and improvements:

View File

@@ -18,7 +18,8 @@ Code contributions are welcome! We use the GitHub PR process to review and merge
#### Tailwind
Bugsink uses tailwind for styling.
Bugsink uses tailwind for styling, and [django-tailwind](https://github.com/timonweb/django-tailwind/)
to "do tailwind stuff from the Django world".
If you're working on HTML, you should probably develop while running the following somewhere:

View File

@@ -49,4 +49,6 @@ RUN pip install -e .
RUN ["bugsink-manage", "migrate", "snappea", "--database=snappea"]
CMD [ "monofy", "bugsink-manage", "check", "--deploy", "--fail-level", "WARNING", "&&", "bugsink-manage", "migrate", "&&", "bugsink-manage", "prestart", "&&", "gunicorn", "--bind=0.0.0.0:$PORT", "--workers=10", "--access-logfile", "-", "bugsink.wsgi", "|||", "bugsink-runsnappea"]
HEALTHCHECK CMD python -c 'import requests; requests.get("http://localhost:8000/health/ready").raise_for_status()'
CMD [ "monofy", "bugsink-manage", "check", "--deploy", "--fail-level", "WARNING", "&&", "bugsink-manage", "migrate", "&&", "bugsink-manage", "prestart", "&&", "gunicorn", "--config", "gunicorn.docker.conf.py", "--bind=0.0.0.0:$PORT", "--access-logfile", "-", "bugsink.wsgi", "|||", "bugsink-runsnappea"]

View File

@@ -71,7 +71,10 @@ RUN --mount=type=cache,target=/var/cache/buildkit/pip \
pip install /wheels/$WHEEL_FILE
COPY bugsink/conf_templates/docker.py.template bugsink_conf.py
COPY gunicorn.docker.conf.py /app/
RUN ["bugsink-manage", "migrate", "snappea", "--database=snappea"]
CMD [ "monofy", "bugsink-manage", "check", "--deploy", "--fail-level", "WARNING", "&&", "bugsink-manage", "migrate", "&&", "bugsink-manage", "prestart", "&&", "gunicorn", "--bind=0.0.0.0:$PORT", "--workers=10", "--access-logfile", "-", "bugsink.wsgi", "|||", "bugsink-runsnappea"]
HEALTHCHECK CMD python -c 'import requests; requests.get("http://localhost:8000/health/ready").raise_for_status()'
CMD [ "monofy", "bugsink-manage", "check", "--deploy", "--fail-level", "WARNING", "&&", "bugsink-manage", "migrate", "&&", "bugsink-manage", "prestart", "&&", "gunicorn", "--config", "gunicorn.docker.conf.py", "--bind=0.0.0.0:$PORT", "--access-logfile", "-", "bugsink.wsgi", "|||", "bugsink-runsnappea"]

View File

@@ -1,16 +1,12 @@
# Bugsink: Self-hosted Error Tracking
[Bugsink](https://www.bugsink.com/) offers [Error Tracking](https://www.bugsink.com/error-tracking/) for your applications with full control
through self-hosting.
* [Error Tracking](https://www.bugsink.com/error-tracking/)
* [Built to self-host](https://www.bugsink.com/built-to-self-host/)
* [Sentry-SDK compatible](https://www.bugsink.com/connect-any-application/)
* [Scalable and reliable](https://www.bugsink.com/scalable-and-reliable/)
### Screenshot
This is what you'll get:
![Screenshot](https://www.bugsink.com/static/images/JsonSchemaDefinitionException.5e02c1544273.png)
@@ -22,7 +18,7 @@ The **quickest way to evaluate Bugsink** is to spin up a throw-away instance usi
docker pull bugsink/bugsink:latest
docker run \
-e SECRET_KEY={{ random_secret }} \
-e SECRET_KEY=PUT_AN_ACTUAL_RANDOM_SECRET_HERE_OF_AT_LEAST_50_CHARS \
-e CREATE_SUPERUSER=admin:admin \
-e PORT=8000 \
-p 8000:8000 \

21
alerts/forms.py Normal file
View File

@@ -0,0 +1,21 @@
from django.forms import ModelForm
from .models import MessagingServiceConfig
class MessagingServiceConfigForm(ModelForm):
def __init__(self, project, *args, **kwargs):
super().__init__(*args, **kwargs)
self.project = project
class Meta:
model = MessagingServiceConfig
fields = ["display_name", "kind"]
def save(self, commit=True):
instance = super().save(commit=False)
instance.project = self.project
if commit:
instance.save()
return instance

View File

@@ -0,0 +1,52 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("projects", "0011_fill_stored_event_count"),
]
operations = [
migrations.CreateModel(
name="MessagingServiceConfig",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"display_name",
models.CharField(
help_text='For display in the UI, e.g. "#general on company Slack"',
max_length=100,
),
),
(
"kind",
models.CharField(
choices=[("slack", "Slack (or compatible)")],
default="slack",
max_length=20,
),
),
("config", models.TextField()),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="service_configs",
to="projects.project",
),
),
],
),
]

View File

@@ -0,0 +1,23 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
# Django came up with 0014, whatever the reason, I'm sure that 0013 is at least required (as per comments there)
("projects", "0014_alter_projectmembership_project"),
("alerts", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="messagingserviceconfig",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="service_configs",
to="projects.project",
),
),
]

View File

View File

@@ -1 +1,18 @@
# Create your models here.
from django.db import models
from projects.models import Project
from .service_backends.slack import SlackBackend
class MessagingServiceConfig(models.Model):
project = models.ForeignKey(Project, on_delete=models.DO_NOTHING, related_name="service_configs")
display_name = models.CharField(max_length=100, blank=False,
help_text='For display in the UI, e.g. "#general on company Slack"')
kind = models.CharField(choices=[("slack", "Slack (or compatible)"), ], max_length=20, default="slack")
config = models.TextField(blank=False)
def get_backend(self):
# once we have multiple backends: lookup by kind.
return SlackBackend(self)

View File

View File

@@ -0,0 +1,164 @@
import json
import requests
from django import forms
from django.template.defaultfilters import truncatechars
from snappea.decorators import shared_task
from bugsink.app_settings import get_settings
from issues.models import Issue
class SlackConfigForm(forms.Form):
webhook_url = forms.URLField(required=True)
def __init__(self, *args, **kwargs):
config = kwargs.pop("config", None)
super().__init__(*args, **kwargs)
if config:
self.fields["webhook_url"].initial = config.get("webhook_url", "")
def get_config(self):
return {
"webhook_url": self.cleaned_data.get("webhook_url"),
}
def _safe_markdown(text):
# Slack assigns a special meaning to some characters, so we need to escape them
# to prevent them from being interpreted as formatting/special characters.
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("*", "\\*").replace("_", "\\_")
@shared_task
def slack_backend_send_test_message(webhook_url, project_name, display_name):
# See Slack's Block Kit Builder
data = {"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "TEST issue",
},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Test message by Bugsink to test the webhook setup.",
},
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*project*: " + _safe_markdown(project_name),
},
{
"type": "mrkdwn",
"text": "*message backend*: " + _safe_markdown(display_name),
},
]
}
]}
result = requests.post(
webhook_url,
data=json.dumps(data),
headers={"Content-Type": "application/json"},
)
result.raise_for_status()
@shared_task
def slack_backend_send_alert(webhook_url, issue_id, state_description, alert_article, alert_reason, unmute_reason=None):
issue = Issue.objects.get(id=issue_id)
issue_url = get_settings().BASE_URL + issue.get_absolute_url()
link = f"<{issue_url}|" + _safe_markdown(truncatechars(issue.title().replace("|", ""), 200)) + ">"
sections = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"{alert_reason} issue",
},
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": link,
},
},
]
if unmute_reason:
sections.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": unmute_reason,
},
})
# assumption: visavis email, project.name is of less importance, because in slack-like things you may (though not
# always) do one-channel per project. more so for site_title (if you have multiple Bugsinks, you'll surely have
# multiple slack channels)
fields = {
"project": issue.project.name
}
# left as a (possible) TODO, because the amount of refactoring (passing event to this function) is too big for now
# if event.release:
# fields["release"] = event.release
# if event.environment:
# fields["environment"] = event.environment
data = {"blocks": sections + [
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": f"*{field}*: " + _safe_markdown(value),
} for field, value in fields.items()
]
},
]}
result = requests.post(
webhook_url,
data=json.dumps(data),
headers={"Content-Type": "application/json"},
)
result.raise_for_status()
class SlackBackend:
def __init__(self, service_config):
self.service_config = service_config
def get_form_class(self):
return SlackConfigForm
def send_test_message(self):
slack_backend_send_test_message.delay(
json.loads(self.service_config.config)["webhook_url"],
self.service_config.project.name,
self.service_config.display_name,
)
def send_alert(self, issue_id, state_description, alert_article, alert_reason, **kwargs):
slack_backend_send_alert.delay(
json.loads(self.service_config.config)["webhook_url"],
issue_id, state_description, alert_article, alert_reason, **kwargs)

View File

@@ -63,9 +63,21 @@ def send_unmute_alert(issue_id, unmute_reason):
def _send_alert(issue_id, state_description, alert_article, alert_reason, **kwargs):
# NOTE: as it stands, there is a bit of asymmetry here: _send_alert is always called in delayed fashion; it delays
# some work itself (message backends) though not all (emails). I kept it like this to be able to add functionality
# without breaking too much (in particular, I like the 3 entry points (send_xx_alert) in the current setup). The
# present solution at least has the advantage that possibly frickle external calls don't break each other.
# The way forward is probably to keep the single 3-way callpoint, but make that non-delayed, and do the calls of
# both message-service and email based alerts in delayed fashion.
from issues.models import Issue # avoid circular import
issue = Issue.objects.get(id=issue_id)
for service in issue.project.service_configs.all():
service_backend = service.get_backend()
service_backend.send_alert(issue_id, state_description, alert_article, alert_reason, **kwargs)
for user in _get_users_for_email_alert(issue):
send_rendered_email(
subject=f'"{truncatechars(issue.title(), 80)}" in "{issue.project.name}" ({state_description})',

View File

@@ -1,3 +1,6 @@
import os
import urllib.parse
from django.core.checks import Warning, register
from django.conf import settings
@@ -31,3 +34,45 @@ def check_event_storage_properly_configured(app_configs, **kwargs):
id="bsmain.W002",
))
return errors
@register("bsmain")
def check_base_url_is_url(app_configs, **kwargs):
try:
parts = urllib.parse.urlsplit(str(get_settings().BASE_URL))
except ValueError as e:
return [Warning(
str(e),
id="bsmain.W003",
)]
if parts.scheme not in ["http", "https"]:
return [Warning(
"The BASE_URL setting must be a valid URL (starting with http or https).",
id="bsmain.W003",
)]
if not parts.hostname:
return [Warning(
"The BASE_URL setting must be a valid URL. The hostname must be set.",
id="bsmain.W003",
)]
return []
@register("bsmain")
def check_proxy_env_vars_consistency(app_configs, **kwargs):
# in this check we straight-up check the os.environ: we can't rely on settings.BEHIND_HTTPS_PROXY to have been set
# since it's Docker-only.
if (
os.getenv("BEHIND_HTTPS_PROXY", "False").lower() in ("true", "1", "yes") and
os.getenv("BEHIND_PLAIN_HTTP_PROXY", "False").lower() in ("true", "1", "yes")
):
return [Warning(
"BEHIND_HTTPS_PROXY and BEHIND_PLAIN_HTTP_PROXY are mutually exclusive.",
id="bsmain.W004",
)]
return []

View File

@@ -19,7 +19,7 @@ class Command(BaseCommand):
if ":" not in os.getenv("CREATE_SUPERUSER"):
raise ValueError("CREATE_SUPERUSER should be in the format 'username:password'")
username, password = os.getenv("CREATE_SUPERUSER").split(":")
username, password = os.getenv("CREATE_SUPERUSER").split(":", 1)
if User.objects.all().exists():
print(

View File

@@ -28,7 +28,7 @@ class Command(BaseCommand):
parser.add_argument("--fresh-trace", action="store_true")
parser.add_argument("--tag", nargs="*", action="append")
parser.add_argument("--compress", action="store", choices=["gzip", "deflate", "br"], default=None)
parser.add_argument("--use-envelope", action="store_true")
parser.add_argument("--use-store-api", action="store_true", help="Use (deprecated) /api/<id>/store/")
parser.add_argument("--chunked-encoding", action="store_true")
parser.add_argument(
"--x-forwarded-for", action="store",
@@ -60,7 +60,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
compress = options['compress']
use_envelope = options['use_envelope']
use_envelope = not options['use_store_api']
dsn = options['dsn']
successfully_sent = []

View File

@@ -11,11 +11,24 @@ import requests
from django.core.management.base import BaseCommand
from compat.dsn import get_store_url, get_envelope_url, get_header_value
from compat.dsn import get_envelope_url, get_header_value
from bugsink.streams import compress_with_zlib, WBITS_PARAM_FOR_GZIP, WBITS_PARAM_FOR_DEFLATE
from issues.utils import get_values
def random_postfix():
# avoids numbers, because when usedd in the type I imagine numbers may at some point be ignored in the grouping.
random_number = random.random()
if random_number < 0.1:
# 10% of the time we simply sample from 1M to create a "fat tail".
unevenly_distributed_number = int(random.random() * 1_000_000)
else:
unevenly_distributed_number = int(1 / random_number)
return "".join([chr(ord("A") + int(c)) for c in str(unevenly_distributed_number)])
class Command(BaseCommand):
def add_arguments(self, parser):
@@ -23,12 +36,10 @@ class Command(BaseCommand):
parser.add_argument("--requests", type=int, default=1)
parser.add_argument("--dsn", nargs="+", action="extend")
parser.add_argument("--fresh-id", action="store_true")
parser.add_argument("--fresh-timestamp", action="store_true")
parser.add_argument("--fresh-trace", action="store_true")
parser.add_argument("--tag", nargs="*", action="append")
parser.add_argument("--compress", action="store", choices=["gzip", "deflate", "br"], default=None)
parser.add_argument("--use-envelope", action="store_true")
parser.add_argument("--random-type", action="store_true", default=False) # generate random exception type
parser.add_argument("filename")
@@ -38,12 +49,7 @@ class Command(BaseCommand):
signal.signal(signal.SIGINT, self.handle_signal)
compress = options['compress']
use_envelope = options['use_envelope']
# non-envelope mode is deprecated by Sentry; we only implement DIGEST_IMMEDIATELY=True for that mode which is
# usually not what we want to do our stress-tests for. (if this assumption is still true later in 2024, we can
# just remove the non-envelope mode support completely.)
assert use_envelope, "Only envelope mode is supported"
dsns = options['dsn']
json_filename = options["filename"]
@@ -57,7 +63,7 @@ class Command(BaseCommand):
prepared_data[i_thread] = {}
for i_request in range(options["requests"]):
prepared_data[i_thread][i_request] = self.prepare(
data, options, i_thread, i_request, compress, use_envelope)
data, options, i_thread, i_request, compress)
timings[i_thread] = []
@@ -65,7 +71,7 @@ class Command(BaseCommand):
t0 = time.time()
for i in range(options["threads"]):
t = threading.Thread(target=self.loop_send_to_server, args=(
dsns, options, use_envelope, compress, prepared_data[i], timings[i]))
dsns, options, compress, prepared_data[i], timings[i]))
t.start()
print("waiting for threads to finish")
@@ -77,7 +83,7 @@ class Command(BaseCommand):
self.print_stats(options["threads"], options["requests"], total_time, timings)
print("done")
def prepare(self, data, options, i_thread, i_request, compress, use_envelope):
def prepare(self, data, options, i_thread, i_request, compress):
if "timestamp" not in data or options["fresh_timestamp"]:
# weirdly enough a large numer of sentry test data don't actually have this required attribute set.
# thus, we set it to something arbitrary on the sending side rather than have our server be robust
@@ -88,8 +94,8 @@ class Command(BaseCommand):
data["timestamp"] = time.time()
if options["fresh_id"]:
data["event_id"] = uuid.uuid4().hex
# in stress tests, we generally send many events, so they must be unique to be meaningful.
data["event_id"] = uuid.uuid4().hex
if options["fresh_trace"]:
if "contexts" not in data:
@@ -112,28 +118,19 @@ class Command(BaseCommand):
k, v = tag.split(":", 1)
if v == "RANDOM":
# avoids numbers in the type because I imagine numbers may at some point be ignored in the grouping.
into_chars = lambda i: "".join([chr(ord("A") + int(c)) for c in str(i)]) # noqa
unevenly_distributed_number = int(1 / (random.random() + 0.0000001))
v = "value-" + into_chars(unevenly_distributed_number)
v = "value-" + random_postfix()
data["tags"][k] = v
if options["random_type"]:
# avoids numbers in the type because I imagine numbers may at some point be ignored in the grouping.
into_chars = lambda i: "".join([chr(ord("A") + int(c)) for c in str(i)]) # noqa
unevenly_distributed_number = int(1 / (random.random() + 0.0000001))
values = get_values(data["exception"])
values[0]["type"] = "Exception" + into_chars(unevenly_distributed_number)
values[0]["type"] = "Exception" + random_postfix()
data_bytes = json.dumps(data).encode("utf-8")
if use_envelope:
# the smallest possible envelope:
data_bytes = (b'{"event_id": "%s"}\n{"type": "event"}\n' % (data["event_id"]).encode("utf-8") +
data_bytes)
# the smallest possible envelope:
data_bytes = (b'{"event_id": "%s"}\n{"type": "event"}\n' % (data["event_id"]).encode("utf-8") +
data_bytes)
if compress in ["gzip", "deflate"]:
if compress == "gzip":
@@ -152,19 +149,19 @@ class Command(BaseCommand):
return compressed_data
def loop_send_to_server(self, dsns, options, use_envelope, compress, compressed_datas, timings):
def loop_send_to_server(self, dsns, options, compress, compressed_datas, timings):
for compressed_data in compressed_datas.values():
if self.stopping:
return
dsn = random.choice(dsns)
t0 = time.time()
success = Command.send_to_server(dsn, options, use_envelope, compress, compressed_data)
success = Command.send_to_server(dsn, options, compress, compressed_data)
taken = time.time() - t0
timings.append((success, taken))
@staticmethod
def send_to_server(dsn, options, use_envelope, compress, compressed_data):
def send_to_server(dsn, options, compress, compressed_data):
try:
headers = {
"Content-Type": "application/json",
@@ -179,7 +176,7 @@ class Command(BaseCommand):
headers["Content-Encoding"] = "deflate"
response = requests.post(
get_envelope_url(dsn) if use_envelope else get_store_url(dsn),
get_envelope_url(dsn),
headers=headers,
data=compressed_data,
)
@@ -187,13 +184,13 @@ class Command(BaseCommand):
elif compress == "br":
headers["Content-Encoding"] = "br"
response = requests.post(
get_envelope_url(dsn) if use_envelope else get_store_url(dsn),
get_envelope_url(dsn),
headers=headers,
data=compressed_data,
)
response = requests.post(
get_envelope_url(dsn) if use_envelope else get_store_url(dsn),
get_envelope_url(dsn),
headers=headers,
data=compressed_data,
)

View File

@@ -13,7 +13,7 @@
<ul class="mb-4">
{% for message in messages %}
{# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
<li class="bg-cyan-50 border-2 border-cyan-800 p-4 rounded-lg">{{ message }}</li>
<li class="bg-cyan-50 dark:bg-cyan-900 border-2 border-cyan-800 dark:border-cyan-400 p-4 rounded-lg">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
@@ -24,9 +24,9 @@
<div class="ml-auto mt-6">
<form action="{% url "auth_token_create" %}" method="post">
{% csrf_token %} {# margins display slightly different from the <a href version that I have for e.g. project memembers, but I don't care _that_ much #}
<button class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md">Add Token</button>
<button class="font-bold text-slate-800 dark:text-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 active:ring rounded-md">Add Token</button>
</form>
</div>
</div>
</div>
<div>
@@ -36,12 +36,12 @@
<table class="w-full mt-8">
<tbody>
<thead>
<tr class="bg-slate-200">
<tr class="bg-slate-200 dark:bg-slate-800">
<th class="w-full p-4 text-left text-xl" colspan="2">Auth Tokens</th>
</tr>
{% for auth_token in auth_tokens %}
<tr class="bg-white border-slate-200 border-b-2">
<tr class="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 border-b-2">
<td class="w-full p-4">
<div>
{{ auth_token.token }}
@@ -50,13 +50,13 @@
<td class="p-4">
<div class="flex justify-end">
<button name="action" value="delete:{{ auth_token.id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Delete</button>
</div>
<button name="action" value="delete:{{ auth_token.id }}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-md">Delete</button>
</div>
</td>
</tr>
{% empty %}
<tr class="bg-white border-slate-200 border-b-2">
<tr class="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 border-b-2">
<td class="w-full p-4">
<div>
No Auth Tokens.

View File

@@ -44,6 +44,8 @@ DEFAULTS = {
"DIGEST_IMMEDIATELY": True,
"VALIDATE_ON_DIGEST": "none", # other legal values are "warn" and "strict"
"KEEP_ENVELOPES": 0, # set to a number to store that many; 0 means "store none". This is for debugging.
"API_LOG_UNIMPLEMENTED_CALLS": False, # if True, log unimplemented API calls; see #153
"KEEP_ARTIFACT_BUNDLES": False, # if True, artifact bundles are kept in the database on-upload (for debugging)
# MAX* below mirror the (current) values for the Sentry Relay
"MAX_EVENT_SIZE": _MEBIBYTE,
@@ -95,6 +97,14 @@ def _sanitize(settings):
if settings["BASE_URL"].endswith("/"):
settings["BASE_URL"] = settings["BASE_URL"][:-1]
if settings["SINGLE_USER"]:
# this is implemented as a "hard imply". Pro: 'it just works' even when configurations are half-baked; con: may
# be confusing if you run into the "I thought I set that like so" case. On balance: I'd rather "just fix it"
# than raise some warning/error and have people deal with that.
settings["SINGLE_TEAM"] = True
settings["USER_REGISTRATION"] = CB_NOBODY
settings["TEAM_CREATION"] = CB_NOBODY
def get_settings():
global _settings

View File

@@ -20,14 +20,17 @@ DEBUG_CSRF = "USE_DEBUG" if os.getenv("DEBUG_CSRF") == "USE_DEBUG" else os.geten
SECRET_KEY = os.getenv("SECRET_KEY")
if os.getenv("BEHIND_HTTPS_PROXY", "False").lower() in ("true", "1", "yes"):
# We hard-tie the ideas of "behind a proxy" and "use https" together: there is no reason to go through the trouble
# of setting up a proxy if you're not using https. We also hard-code some choices of proxy headers; which means you
# have to match those on the proxy side (but that's easy enough).
BEHIND_HTTPS_PROXY = os.getenv("BEHIND_HTTPS_PROXY", "False").lower() in ("true", "1", "yes")
BEHIND_PLAIN_HTTP_PROXY = os.getenv("BEHIND_PLAIN_HTTP_PROXY", "False").lower() in ("true", "1", "yes")
if BEHIND_HTTPS_PROXY or BEHIND_PLAIN_HTTP_PROXY:
# We hard-code the choice of proxy headers; which means you have to match those on the proxy side (easy enough).
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Note: slightly redundant, Gunicorn also does this.
USE_X_REAL_IP = True
if BEHIND_HTTPS_PROXY:
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
USE_X_REAL_IP = True
else:
# We don't warn about SESSION_COOKIE_SECURE and CSRF_COOKIE_SECURE; we can't set them to True because some browsers
# interpret that as "use https for cookies, even on localhost", and there is no https (BEHIND_HTTPS_PROXY is False).
@@ -38,6 +41,7 @@ else:
]
# The time-zone here is the default for display purposes (when no project/user configuration is used).
# https://docs.djangoproject.com/en/4.2/ref/settings/#std-setting-TIME_ZONE
TIME_ZONE = os.getenv("TIME_ZONE", "UTC")
@@ -159,6 +163,10 @@ BUGSINK = {
# Settings that help with debugging and development ("why isn't Bugsink doing what I expect?")
"VALIDATE_ON_DIGEST": os.getenv("VALIDATE_ON_DIGEST", "none").lower(), # other legal values are "warn" and "strict"
"KEEP_ENVELOPES": int(os.getenv("KEEP_ENVELOPES", 0)), # keep this many in the database; 0 means "don't keep"
"API_LOG_UNIMPLEMENTED_CALLS": os.getenv("API_LOG_UNIMPLEMENTED_CALLS", "false").lower() in ("true", "1", "yes"),
"KEEP_ARTIFACT_BUNDLES": os.getenv("KEEP_ARTIFACT_BUNDLES", "false").lower() in ("true", "1", "yes"),
"MINIMIZE_INFORMATION_EXPOSURE":
os.getenv("MINIMIZE_INFORMATION_EXPOSURE", "false").lower() in ("true", "1", "yes"),

View File

@@ -21,15 +21,10 @@ from phonehome.models import Installation
SystemWarning = namedtuple('SystemWarning', ['message', 'ignore_url'])
FREE_VERSION_WARNING = mark_safe(
"""This is the free version of Bugsink; usage is limited to a single user for local development only.
Using this software in production requires a
<a href="https://www.bugsink.com/#pricing" target="_blank" class="font-bold text-slate-800">paid licence</a>.""")
EMAIL_BACKEND_WARNING = mark_safe(
"""Email is not set up, emails won't be sent. To get the most out of Bugsink, please
<a href="https://www.bugsink.com/docs/settings/#email" target="_blank" class="font-bold text-slate-800">set up
email</a>.""")
<a href="https://www.bugsink.com/docs/settings/#email" target="_blank" class="font-bold text-slate-800
dark:text-slate-100">set up email</a>.""")
def get_snappea_warnings():
@@ -97,35 +92,40 @@ def get_snappea_warnings():
def useful_settings_processor(request):
# name is misnomer, but "who cares".
"""Adds useful settings (and more) to the context."""
installation = Installation.objects.get()
def get_system_warnings():
# implemented as an inner function to avoid calculating this when it's not actually needed. (i.e. anything
# except "the UI", e.g. ingest, API, admin, 404s). Actual 'cache' behavior is not needed, because this is called
# at most once per request (at the top of the template)
installation = Installation.objects.get()
system_warnings = []
system_warnings = []
# This list does not include e.g. the dummy.EmailBackend; intentional, because setting _that_ is always an
# indication of intentional "shut up I don't want to send emails" (and we don't want to warn about that). (as
# opposed to QuietConsoleEmailBackend, which is the default for the Docker "no EMAIL_HOST set" situation)
if settings.EMAIL_BACKEND in [
'bugsink.email_backends.QuietConsoleEmailBackend'] and not installation.silence_email_system_warning:
# This list does not include e.g. the dummy.EmailBackend; intentional, because setting _that_ is always an
# indication of intentional "shut up I don't want to send emails" (and we don't want to warn about that). (as
# opposed to QuietConsoleEmailBackend, which is the default for the Docker "no EMAIL_HOST set" situation)
if settings.EMAIL_BACKEND in [
'bugsink.email_backends.QuietConsoleEmailBackend'] and not installation.silence_email_system_warning:
if getattr(request, "user", AnonymousUser()).is_superuser:
ignore_url = reverse("silence_email_system_warning")
else:
# not a superuser, so can't silence the warning. I'm applying some heuristics here;
# * superusers (and only those) will be able to deal with this (have access to EMAIL_BACKEND)
# * better to still show (though not silencable) the message to non-superusers.
# this will not always be so, but it's a good start.
ignore_url = None
if getattr(request, "user", AnonymousUser()).is_superuser:
ignore_url = reverse("silence_email_system_warning")
else:
# not a superuser, so can't silence the warning. I'm applying some heuristics here;
# * superusers (and only those) will be able to deal with this (have access to EMAIL_BACKEND)
# * better to still show (though not silencable) the message to non-superusers.
# this will not always be so, but it's a good start.
ignore_url = None
system_warnings.append(SystemWarning(EMAIL_BACKEND_WARNING, ignore_url))
system_warnings.append(SystemWarning(EMAIL_BACKEND_WARNING, ignore_url))
return system_warnings + get_snappea_warnings()
return {
# Note: no way to actually set the license key yet, so nagging always happens for now.
'site_title': get_settings().SITE_TITLE,
'registration_enabled': get_settings().USER_REGISTRATION == CB_ANYBODY,
'app_settings': get_settings(),
'system_warnings': system_warnings + get_snappea_warnings(),
'system_warnings': get_system_warnings,
}

View File

@@ -217,11 +217,10 @@ def csrf_debug(request):
"posted": True,
"POST": request.POST,
"META": {
k: request.META.get(k) for k in [
"HTTP_ORIGIN",
"HTTP_REFERER",
]
k: request.META.get(k) for k in request.META.keys() if k.startswith("HTTP_")
},
"SECURE_PROXY_SSL_HEADER": settings.SECURE_PROXY_SSL_HEADER[0] if settings.SECURE_PROXY_SSL_HEADER else None,
"process_view": _process_view_steps(middleware, request, context),
})

View File

@@ -39,7 +39,7 @@ def issue_membership_required(function):
if "issue_pk" not in kwargs:
raise TypeError("issue_pk must be passed as a keyword argument")
issue_pk = kwargs.pop("issue_pk")
issue = get_object_or_404(Issue, pk=issue_pk)
issue = get_object_or_404(Issue, pk=issue_pk, is_deleted=False)
kwargs["issue"] = issue
if request.user.is_superuser:
return function(request, *args, **kwargs)

View File

@@ -89,6 +89,11 @@ class SetRemoteAddrMiddleware:
@staticmethod
def parse_x_forwarded_for(header_value):
# NOTE: our method parsing _does not_ remove port numbers from the X-Forwarded-For header; such setups are rare
# (but legal according to the spec) but [1] we don't recommend them and [2] we recommend X-Real-IP over
# X-Forwarded-For anyway.
# https://serverfault.com/questions/753682/iis-server-farm-with-arr-why-does-http-x-forwarded-for-have-a-port-nu
if header_value in [None, ""]:
# The most typical misconfiguration is to forget to set the header at all, or to have it be empty. In that
# case, we'll just set the IP to None, which will mean some data will be missing from your events (but
@@ -116,6 +121,7 @@ class SetRemoteAddrMiddleware:
def __call__(self, request):
if settings.USE_X_REAL_IP:
# NOTE: X-Real-IP never contains a port number AFAICT by searching online so the below is IP-only:
request.META["REMOTE_ADDR"] = request.META.get("HTTP_X_REAL_IP", None)
elif settings.USE_X_FORWARDED_FOR: # elif: X-Real-IP / X-Forwarded-For are mutually exclusive

View File

@@ -16,6 +16,7 @@ from os.path import basename
from pygments.lexers import (
ActionScript3Lexer, CLexer, ColdfusionHtmlLexer, CSharpLexer, HaskellLexer, GoLexer, GroovyLexer, JavaLexer,
JavascriptLexer, ObjectiveCLexer, PerlLexer, PhpLexer, PythonLexer, RubyLexer, TextLexer, XmlPhpLexer,
PowerShellLexer, CrystalLexer
)
_all_lexers = None
@@ -105,7 +106,7 @@ def guess_lexer_for_filename(_fn, platform, code=None, **options):
def lexer_for_platform(platform, **options):
# We can depend on platform having been set: it's a required attribute as per Sentry's docs.
# The LHS in the table below is a fixed list of available platforms, as per the Sentry docs.
# The LHS in the table below is a fixed list of available platforms, as per the Sentry docs. (but: #143, #145)
# The RHS is my educated guess for what these platforms map to in Pygments.
clz = {
@@ -114,6 +115,7 @@ def lexer_for_platform(platform, **options):
"cfml": ColdfusionHtmlLexer,
"cocoa": TextLexer, # I couldn't find the Cocoa lexer in Pygments, this will do for now.
"csharp": CSharpLexer,
"crystal": CrystalLexer, # _not_ in the list of "acceptable platforms", but "seen in the wild" (#145)
"elixir": TextLexer, # I couldn't find the Elixir lexer in Pygments, this will do for now.
"haskell": HaskellLexer,
"go": GoLexer,
@@ -131,9 +133,10 @@ def lexer_for_platform(platform, **options):
"other": TextLexer, # "other" by definition implies that nothing is known.
"perl": PerlLexer, # or Perl6Lexer...
"php": PhpLexer,
"powershell": PowerShellLexer, # _not_ in the list of "acceptable platforms", but "seen in the wild" (#143)
"python": PythonLexer,
"ruby": RubyLexer,
}[platform]
}.get(platform, TextLexer) # default to TextLexer if not found; see #143 and #145 for why we fall back at all
options = _custom_options(clz, options)
return clz(**options)

42
bugsink/scripts/util.py Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python
"""A copy of the Django-generated manage.py, but:
* in the bugsink.scripts package, such that it can be wrapped by a setuptools-installable script
* with the DJANGO_SETTINGS_MODULE set to `bugsink.settings.default` by default.
This script can be used to run Django management commands for which the settings _don't matter_.
Such commands "should probably" be extracted to be Django-independent, but that incurs its own extra work (as well as
future maintenance burden): some utility code is shared, the command utilizes the Django argv parsing, and a separate
repo _always_ brings extra overhead (e.g. for testing, CI, etc.). So this is a pragmatic solution to the problem.
"""
import os
import sys
def find_commands(management_dir):
# explicitly enumerate Django (settings)-independent commands here (for --help)
if 'bsmain' in management_dir:
return ["stress_test", "send_json"]
return []
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bugsink.settings.default')
try:
# we just monkeypatch the find_commands function to return the commands which are actually settings-independent.
import django.core.management
django.core.management.find_commands = find_commands
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@@ -188,10 +188,17 @@ DATABASES = {
},
# This is a "database as message queue" setup; If you're reading this and are thinking of replacing this particular
# DB with mysql/postgres, know that you "probably shouldn't". https://www.bugsink.com/blog/snappea-design/
# DB with mysql/postgres, know that you "probably shouldn't".
#
# Regarding the location (NAME) of the file: the expectation is that this is local to your Bugsink instance, and
# that there is a your setup has exactly an equal number of [a] gunicorn webservers, [b] snappea foremans, and [c]
# snappea databases; [d] machines/containers (with a strong preference for that number being 1). In short: you
# should probably not touch this, and if you're thinking of pointing to a mounted volume you probably are
# misunderstanding what this is for.
# https://www.bugsink.com/blog/snappea-design/
"snappea": {
'ENGINE': 'bugsink.timed_sqlite_backend',
'NAME': os.getenv("SNAPPEA_DATABASE_PATH", 'snappea.sqlite3'),
'NAME': os.getenv("SNAPPEA_DATABASE_PATH", 'snappea.sqlite3'), # NOTE: read the above comment
# 'TEST': { postponed, for starters we'll do something like SNAPPEA_ALWAYS_EAGER
'OPTIONS': {
'timeout': 5,

View File

@@ -1,10 +1,9 @@
from .default import * # noqa
from .default import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, LOGGING, DATABASES, I_AM_RUNNING
from .default import BASE_DIR, LOGGING, DATABASES, I_AM_RUNNING
import os
from sentry_sdk_extensions.transport import MoreLoudlyFailingTransport
from debug_toolbar.middleware import show_toolbar
from bugsink.utils import deduce_allowed_hosts, eat_your_own_dogfood
@@ -13,33 +12,6 @@ SECRET_KEY = 'django-insecure-$@clhhieazwnxnha-_zah&(bieq%yux7#^07&xsvhn58t)8@xw
DEBUG = True
# > The Debug Toolbar is shown only if your IP address is listed in Djangos INTERNAL_IPS setting. This means that for
# > local development, you must add "127.0.0.1" to INTERNAL_IPS.
INTERNAL_IPS = [
"127.0.0.1",
]
if not I_AM_RUNNING == "TEST":
INSTALLED_APPS += [
"debug_toolbar",
]
MIDDLEWARE = [
"debug_toolbar.middleware.DebugToolbarMiddleware",
] + MIDDLEWARE
def show_toolbar_for_queryparam(request):
if "__debug__" not in request.path and not request.GET.get("debug", ""):
return False
return show_toolbar(request)
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": show_toolbar_for_queryparam,
}
# this way of configuring (DB, DB_USER, DB_PASSWORD) is specific to the development environment
if os.getenv("DB", "sqlite") == "mysql":
DATABASES['default'] = {
@@ -91,15 +63,13 @@ SNAPPEA = {
"NUM_WORKERS": 1,
}
POSTMARK_API_KEY = os.getenv('POSTMARK_API_KEY')
EMAIL_HOST = 'smtp.postmarkapp.com'
EMAIL_HOST_USER = POSTMARK_API_KEY
EMAIL_HOST_PASSWORD = POSTMARK_API_KEY
EMAIL_HOST = os.getenv("EMAIL_HOST")
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD")
EMAIL_PORT = 587
EMAIL_USE_TLS = True
SERVER_EMAIL = DEFAULT_FROM_EMAIL = 'Klaas van Schelven <klaas@vanschelven.com>'
SERVER_EMAIL = DEFAULT_FROM_EMAIL = 'Klaas van Schelven <klaas@bugsink.com>'
BUGSINK = {
@@ -122,10 +92,13 @@ BUGSINK = {
"VALIDATE_ON_DIGEST": "warn",
# "KEEP_ENVELOPES": 10,
"API_LOG_UNIMPLEMENTED_CALLS": True,
# set MAX_EVENTS* very high to be able to do serious performance testing (which I do often in my dev environment)
"MAX_EVENTS_PER_PROJECT_PER_5_MINUTES": 1_000_000,
"MAX_EVENTS_PER_PROJECT_PER_HOUR": 50_000_000,
"KEEP_ARTIFACT_BUNDLES": True, # in development: useful to preserve sourcemap uploads
}
@@ -167,3 +140,6 @@ LOGGING["loggers"]["snappea"]["level"] = "DEBUG"
LOGGING["formatters"]["snappea"]["format"] = "{asctime} - {threadName} - {levelname:7} - {message}"
ALLOWED_HOSTS = deduce_allowed_hosts(BUGSINK["BASE_URL"])
# django-tailwind setting; the below allows for environment-variable overriding of the npm binary path.
NPM_BIN_PATH = os.getenv("NPM_BIN_PATH", "npm")

View File

@@ -7,6 +7,7 @@ from unittest import TestCase as RegularTestCase
from django.test import TestCase as DjangoTestCase
from django.test import override_settings
from django.core.exceptions import SuspiciousOperation
from .wsgi import allowed_hosts_error_message
from .volume_based_condition import VolumeBasedCondition
from .streams import (
@@ -376,3 +377,47 @@ class SetRemoteAddrMiddlewareTestCase(RegularTestCase):
with self.assertRaises(SuspiciousOperation):
SetRemoteAddrMiddleware.parse_x_forwarded_for("123.123.123.123,1.2.3.4")
class AllowedHostsMsgTestCase(DjangoTestCase):
def test_allowed_hosts_error_message(self):
self.maxDiff = None
# Note: cases for ALLOWED_HOSTS=[] are redundant because Django will refuse to start in that case.
# ALLOWED_HOST only contains non-production domains that we typically _do not_ want to suggest in the msg
self.assertEqual(
"'Host: foobar' as sent by browser/proxy not in ALLOWED_HOSTS=['localhost', '127.0.0.1']. "
"Add 'foobar' to ALLOWED_HOSTS or configure proxy to use 'Host: your.host.example'.",
allowed_hosts_error_message("foobar", ["localhost", "127.0.0.1"]))
# proxy misconfig: proxy speaks to "localhost"
self.assertEqual(
"'Host: localhost' as sent by browser/proxy not in ALLOWED_HOSTS=['testserver']. "
"Configure proxy to use 'Host: testserver' or add the desired host to ALLOWED_HOSTS.",
allowed_hosts_error_message("localhost", ["testserver"]))
# proxy misconfig: proxy speaks (local) IP
self.assertEqual(
"'Host: 127.0.0.1' as sent by browser/proxy not in ALLOWED_HOSTS=['testserver']. "
"Configure proxy to use 'Host: testserver' or add the desired host to ALLOWED_HOSTS.",
allowed_hosts_error_message("127.0.0.1", ["testserver"]))
# proxy misconfig: proxy speaks (remote) IP
self.assertEqual(
"'Host: 123.123.123.123' as sent by browser/proxy not in ALLOWED_HOSTS=['testserver']. "
"Configure proxy to use 'Host: testserver' or add the desired host to ALLOWED_HOSTS.",
allowed_hosts_error_message("123.123.123.123", ["testserver"]))
# plain old typo ALLOWED_HOSTS-side
self.assertEqual(
"'Host: testserver' as sent by browser/proxy not in ALLOWED_HOSTS=['teeestserver']. "
"Add 'testserver' to ALLOWED_HOSTS or configure proxy to use 'Host: teeestserver'.",
allowed_hosts_error_message("testserver", ["teeestserver"]))
# plain old typo proxy-config-side
self.assertEqual(
"'Host: teeestserver' as sent by browser/proxy not in ALLOWED_HOSTS=['testserver']. "
"Add 'teeestserver' to ALLOWED_HOSTS or configure proxy to use 'Host: testserver'.",
allowed_hosts_error_message("teeestserver", ["testserver"]))

View File

@@ -1,3 +1,4 @@
import logging
from collections import namedtuple
from copy import deepcopy
import time
@@ -10,6 +11,9 @@ from django.db.backends.sqlite3.base import (
DatabaseWrapper as UnpatchedDatabaseWrapper, SQLiteCursorWrapper as UnpatchedSQLiteCursorWrapper,
)
logger = logging.getLogger("bugsink")
# We disinguish between the default runtime limit for a connection (set in the settings) and a runtime limit set by the
# "with different_runtime_limit" idiom, i.e. temporarily. The reason we need to distinguish these two concepts (and keep
# track of their values) explicitly, and provide the fallback getter mechanism (cm if available, otherwise
@@ -42,7 +46,7 @@ def _set_runtime_limit(using, is_default_for_connection, seconds):
)
def _get_runtime_limit(using=None):
def _get_runtime_limit(using):
if using is None:
using = DEFAULT_DB_ALIAS
@@ -76,11 +80,12 @@ def different_runtime_limit(seconds, using=None):
@contextmanager
def limit_runtime(conn):
def limit_runtime(alias, conn, query=None, params=None):
# query & params are only used for logging purposes; they are not used to actually limit the runtime.
start = time.time()
def check_time():
if time.time() > start + _get_runtime_limit():
if time.time() > start + _get_runtime_limit(alias):
return 1
return 0
@@ -93,6 +98,18 @@ def limit_runtime(conn):
yield
if time.time() > start + _get_runtime_limit(alias) + 0.01:
# https://sqlite.org/forum/forumpost/fa65709226 to see why we need this.
#
# Doing an actual timeout _now_ doesn't achieve anything (the goal is generally to avoid things taking too long,
# once you're here only time-travel can help you). So `logger.error()` rather than `raise OperationalError`.
#
# + 0.05s to avoid false positives like so: the query completing in exactly runtime_limit with the final check
# coming a fraction of a second later (0.01s is assumed to be well on the "avoid false positives" side of the
# trade-off)
took = time.time() - start
logger.error("limit_runtime miss (%.3fs): %s %s", took, query, params)
conn.set_progress_handler(None, 0)
@@ -139,23 +156,29 @@ class DatabaseWrapper(UnpatchedDatabaseWrapper):
# return PrintOnClose(result)
def create_cursor(self, name=None):
return self.connection.cursor(factory=SQLiteCursorWrapper)
return self.connection.cursor(factory=get_sqlite_cursor_wrapper(self.alias))
class SQLiteCursorWrapper(UnpatchedSQLiteCursorWrapper):
def get_sqlite_cursor_wrapper(alias):
if alias is None:
alias = DEFAULT_DB_ALIAS
def execute(self, query, params=None):
if settings.I_AM_RUNNING == "MIGRATE":
# migrations in Sqlite are often slow (drop/recreate tables, etc); so we don't want to limit them
return super().execute(query, params)
class SQLiteCursorWrapper(UnpatchedSQLiteCursorWrapper):
with limit_runtime(self.connection):
return super().execute(query, params)
def execute(self, query, params=None):
if settings.I_AM_RUNNING == "MIGRATE":
# migrations in Sqlite are often slow (drop/recreate tables, etc); so we don't want to limit them
return super().execute(query, params)
def executemany(self, query, param_list):
if settings.I_AM_RUNNING == "MIGRATE":
# migrations in Sqlite are often slow (drop/recreate tables, etc); so we don't want to limit them
return super().executemany(query, param_list)
with limit_runtime(alias, self.connection, query=query, params=params):
return super().execute(query, params)
with limit_runtime(self.connection):
return super().executemany(query, param_list)
def executemany(self, query, param_list):
if settings.I_AM_RUNNING == "MIGRATE":
# migrations in Sqlite are often slow (drop/recreate tables, etc); so we don't want to limit them
return super().executemany(query, param_list)
with limit_runtime(alias, self.connection, query=query, params=param_list):
return super().executemany(query, param_list)
return SQLiteCursorWrapper

View File

@@ -7,6 +7,9 @@ import threading
from django.db import transaction as django_db_transaction
from django.db import DEFAULT_DB_ALIAS
from django.conf import settings
from snappea.settings import get_settings as get_snappea_settings
performance_logger = logging.getLogger("bugsink.performance.db")
local_storage = threading.local()
@@ -153,7 +156,7 @@ class ImmediateAtomic(SuperDurableAtomic):
connection = django_db_transaction.get_connection(self.using)
if hasattr(connection, "_start_transaction_under_autocommit"):
connection._start_transaction_under_autocommit_original = connection._start_transaction_under_autocommit
self._start_transaction_under_autocommit_original = connection._start_transaction_under_autocommit
connection._start_transaction_under_autocommit = types.MethodType(
_start_transaction_under_autocommit_patched, connection)
@@ -183,9 +186,9 @@ class ImmediateAtomic(SuperDurableAtomic):
performance_logger.info(f"{took * 1000:6.2f}ms IMMEDIATE transaction{using_clause}")
connection = django_db_transaction.get_connection(self.using)
if hasattr(connection, "_start_transaction_under_autocommit"):
connection._start_transaction_under_autocommit = connection._start_transaction_under_autocommit_original
del connection._start_transaction_under_autocommit_original
if hasattr(self, "_start_transaction_under_autocommit_original"):
connection._start_transaction_under_autocommit = self._start_transaction_under_autocommit_original
del self._start_transaction_under_autocommit_original
@contextmanager
@@ -206,10 +209,32 @@ def immediate_atomic(using=None, savepoint=True, durable=True):
else:
immediate_atomic = ImmediateAtomic(using, savepoint, durable)
# https://stackoverflow.com/a/45681273/339144 provides some context on nesting context managers; and how to proceed
# if you want to do this with an arbitrary number of context managers.
with SemaphoreContext(using), immediate_atomic:
yield
if get_snappea_settings().TASK_ALWAYS_EAGER:
# In ALWAYS_EAGER mode we cannot use SemaphoreContext as the outermost context, because any delay_on_commit
# tasks that are triggered on __exit__ of the (in that case, inner) immediate_atomic, when themselves initiating
# a new task-with-transaction, will not be able to acquire the semaphore (it's not been released yet).
# Fundamentally the solution would be to push the "on commit" logic onto the outermost context, but that seems
# fragile (monkeypatching/heavy overriding) and since the whole SemaphoreContext is only needed as an extra
# guard against WAL growth (not something we care about in the non-production setup), we just simplify for that
# case.
with immediate_atomic:
yield
elif "sqlite" not in settings.DATABASES[using]["ENGINE"]:
# The SemaphoreContext was added specifically to address the WAL growth issue in sqlite; better not to use it
# for other database backends; in particular, if such databases have longer default timeouts, then the error
# message may be confusing (semaphore timeout after 10s throws an error... while the thread that hogs the DB
# is _not_ (yet) timed out)
#
# in-string matching matches both our 'timed' backend and the django default.
with immediate_atomic:
yield
else:
# https://stackoverflow.com/a/45681273/339144 provides some context on nesting context managers; and how to
# proceed if you want to do this with an arbitrary number of context managers.
with SemaphoreContext(using), immediate_atomic:
yield
def delay_on_commit(function, *args, **kwargs):

View File

@@ -3,7 +3,7 @@ from django.conf import settings
from django.contrib import admin
from django.urls import include, path
from django.contrib.auth import views as auth_views
from django.views.generic import RedirectView
from django.views.generic import RedirectView, TemplateView
from alerts.views import debug_email as debug_alerts_email
from users.views import debug_email as debug_users_email
@@ -11,9 +11,10 @@ from teams.views import debug_email as debug_teams_email
from bugsink.app_settings import get_settings
from users.views import signup, confirm_email, resend_confirmation, request_reset_password, reset_password, preferences
from ingest.views import download_envelope
from files.views import chunk_upload, artifact_bundle_assemble
from files.views import chunk_upload, artifact_bundle_assemble, api_catch_all
from bugsink.decorators import login_exempt
from .views import home, trigger_error, favicon, settings_view, silence_email_system_warning, counts
from .views import home, trigger_error, favicon, settings_view, silence_email_system_warning, counts, health_check_ready
from .debug_views import csrf_debug
@@ -25,6 +26,8 @@ admin.site.index_title = "Admin" # everyone calls this the "admin" anyway. Let'
urlpatterns = [
path('', home, name='home'),
path("health/ready", health_check_ready, name="health_check_ready"),
path("accounts/signup/", signup, name="signup"),
path("accounts/resend-confirmation/", resend_confirmation, name="resend_confirmation"),
path("accounts/confirm-email/<str:token>/", confirm_email, name="confirm_email"),
@@ -49,6 +52,8 @@ urlpatterns = [
path('api/', include('ingest.urls')),
path('api/<path:subpath>', api_catch_all, name='api_catch_all'),
# not in /api/ because it's not part of the ingest API, but still part of the ingest app
path('ingest/envelope/<str:envelope_id>/', download_envelope, name='download_envelope'),
@@ -73,6 +78,7 @@ urlpatterns = [
path('debug/csrf/', csrf_debug, name='csrf_debug'),
path("favicon.ico", favicon),
path("robots.txt", login_exempt(TemplateView.as_view(template_name="robots.txt", content_type="text/plain"))),
]
if settings.DEBUG:
@@ -83,14 +89,6 @@ if settings.DEBUG:
path('trigger-error/', trigger_error),
]
try:
import debug_toolbar # noqa
urlpatterns = [
path('__debug__/', include('debug_toolbar.urls')),
] + urlpatterns
except ImportError:
pass
handler400 = "bugsink.views.bad_request"
handler403 = "bugsink.views.permission_denied"

View File

@@ -1,7 +1,10 @@
from collections import defaultdict
from urllib.parse import urlparse
from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from django.apps import apps
from django.db.models import ForeignKey, F
from .version import version
@@ -161,3 +164,150 @@ def eat_your_own_dogfood(sentry_dsn, **kwargs):
sentry_sdk.init(
**default_kwargs,
)
def get_model_topography():
"""
Returns a dependency graph mapping:
referenced_model_key -> [
(referrer_model_class, fk_name),
...
]
"""
dep_graph = defaultdict(list)
for model in apps.get_models():
for field in model._meta.get_fields(include_hidden=True):
if isinstance(field, ForeignKey):
referenced_model = field.related_model
referenced_key = f"{referenced_model._meta.app_label}.{referenced_model.__name__}"
dep_graph[referenced_key].append((model, field.name))
return dep_graph
def fields_for_prune_orphans(model):
if model.__name__ == "IssueTag":
return ("value_id",)
return ()
def prune_orphans(model, d_ids_to_check):
"""For some model, does dangling-model-cleanup.
In a sense the oposite of delete_deps; delete_deps takes care of deleting the recursive closure of things that point
to some root. The present function cleans up things that are being pointed to (and, after some other thing is
deleted, potentially are no longer being pointed to, hence 'orphaned').
This is the hardcoded edition (IssueTag only); we _could_ try to think about doing this generically based on the
dependency graph, but it's quite questionably whether a combination of generic & performant is easy to arrive at and
worth it.
pruning of TagValue is done "inline" (as opposed to using a GC-like vacuum "later") because, whatever the exact
performance trade-offs may be, the following holds true:
1. the inline version is easier to reason about, it "just happens ASAP", and in the context of a given issue;
vacuum-based has to take into consideration the full DB including non-orphaned values.
2. repeated work is somewhat minimalized b/c of the IssueTag/EventTag relationship as described in prune_tagvalues.
"""
from tags.models import prune_tagvalues # avoid circular import
if model.__name__ != "IssueTag":
return # we only prune IssueTag orphans
ids_to_check = [d["value_id"] for d in d_ids_to_check] # d_ids_to_check: mirrors fields_for_prune_orphans(model)
prune_tagvalues(ids_to_check)
def do_pre_delete(project_id, model, pks_to_delete, is_for_project):
"More model-specific cleanup, if needed; only for Event model at the moment."
if model.__name__ != "Event":
return # we only do more cleanup for Event
from projects.models import Project
from events.models import Event
from events.retention import cleanup_events_on_storage
cleanup_events_on_storage(
Event.objects.filter(pk__in=pks_to_delete).exclude(storage_backend=None)
.values_list("id", "storage_backend")
)
if is_for_project:
# no need to update the stored_event_count for the project, because the project is being deleted
return
# Update project stored_event_count to reflect the deletion of the events. note: alternatively, we could do this
# on issue-delete (issue.stored_event_count is known too); potato, potato though.
# note: don't bother to do the same thing for Issue.stored_event_count, since we're in the process of deleting Issue
Project.objects.filter(id=project_id).update(stored_event_count=F('stored_event_count') - len(pks_to_delete))
def delete_deps_with_budget(project_id, referring_model, fk_name, referred_ids, budget, dep_graph, is_for_project):
r"""
Deletes all objects of type referring_model that refer to any of the referred_ids via fk_name.
Returns the number of deleted objects.
And does this recursively (i.e. if there are further dependencies, it will delete those as well).
Caller This Func
| |
V V
<unspecified> referring_model
^ /
\-------fk_name----
referred_ids relevant_ids (deduced using a query)
"""
num_deleted = 0
# Fetch ids of referring objects and their referred ids. Note that an index of fk_name can be assumed to exist,
# because fk_name is a ForeignKey field, and Django automatically creates an index for ForeignKey fields unless
# instructed otherwise: https://github.com/django/django/blob/7feafd79a481/django/db/models/fields/related.py#L1025
relevant_ids = list(
referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).order_by(f"{fk_name}_id", 'pk').values(
*(('pk',) + fields_for_prune_orphans(referring_model))
)[:budget]
)
if not relevant_ids:
# we didn't find any referring objects. optimization: skip any recursion and referring_model.delete()
return 0
# The recursing bit:
for_recursion = dep_graph.get(f"{referring_model._meta.app_label}.{referring_model.__name__}", [])
for model_for_recursion, fk_name_for_recursion in for_recursion:
num_deleted += delete_deps_with_budget(
project_id,
model_for_recursion,
fk_name_for_recursion,
[d["pk"] for d in relevant_ids],
budget - num_deleted,
dep_graph,
is_for_project,
)
if num_deleted >= budget:
return num_deleted
# If this point is reached: we have deleted all referring objects that we could delete, and we still have budget
# left. We can now delete the referring objects themselves (limited by budget).
relevant_ids_after_rec = relevant_ids[:budget - num_deleted]
do_pre_delete(project_id, referring_model, [d['pk'] for d in relevant_ids_after_rec], is_for_project)
my_num_deleted, del_d = referring_model.objects.filter(pk__in=[d['pk'] for d in relevant_ids_after_rec]).delete()
num_deleted += my_num_deleted
assert set(del_d.keys()) == {referring_model._meta.label} # assert no-cascading (we do that ourselves)
if is_for_project:
# short-circuit: project-deletion implies "no orphans" because the project kill everything with it.
return num_deleted
# Note that prune_orphans doesn't respect the budget. Reason: it's not easy to do, b/c the order is reversed (we
# would need to predict somehow at the previous step how much budget to leave unused) and we don't care _that much_
# about a precise budget "at the edges of our algo", as long as we don't have a "single huge blocking thing".
prune_orphans(referring_model, relevant_ids_after_rec)
return num_deleted

View File

@@ -98,6 +98,20 @@ def home(request):
return redirect("team_list")
@login_exempt
def health_check_ready(request):
"""
A simple health check that returns 200 if the server is up and running. To be used in containerized environments
in a way that 'makes sense to you', e.g. as a readiness probe in Kubernetes.
What this "proves" is that the application server is up and accepting requests.
By design, this health check does not check the database connection; we only make a statement about _our own
health_; this is to avoid killing the app-server if the database is down.
"""
return HttpResponse("OK", content_type="text/plain")
@login_exempt
def trigger_error(request):
raise Exception("Exception triggered on purpose to debug error handling")

View File

@@ -13,15 +13,46 @@ import django
from django.core.handlers.wsgi import WSGIHandler, WSGIRequest
from django.core.exceptions import DisallowedHost
from django.http.request import split_domain_port, validate_host
from django.core.validators import validate_ipv46_address
from django.core.exceptions import ValidationError
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bugsink_conf')
def is_ip_address(value):
try:
validate_ipv46_address(value)
return True
except ValidationError:
return False
def allowed_hosts_error_message(domain, allowed_hosts):
# Start with the plain statement of fact: x not in y.
msg = "'Host: %s' as sent by browser/proxy not in ALLOWED_HOSTS=%s. " % (domain, allowed_hosts)
suggestable_allowed_hosts = [host for host in allowed_hosts if host not in ["localhost", ".localhost", "127.0.0.1"]]
if len(suggestable_allowed_hosts) == 0:
proxy_suggestion = "your.host.example"
else:
proxy_suggestion = " | ".join(suggestable_allowed_hosts)
if domain == "localhost" or is_ip_address(domain):
# in these cases Proxy misconfig is the more likely culprit. Point to that _first_ and (while still mentioning
# ALLOWED_HOSTS); don't mention the specific domain that was used as a likely "good value" for ALLLOWED_HOSTS.
return msg + "Configure proxy to use 'Host: %s' or add the desired host to ALLOWED_HOSTS." % proxy_suggestion
# the domain looks "pretty good"; be verbose/explicit about the 2 possible changes in config.
return msg + "Add '%s' to ALLOWED_HOSTS or configure proxy to use 'Host: %s'." % (domain, proxy_suggestion)
class CustomWSGIRequest(WSGIRequest):
"""
Custom WSQIRequest subclass with 2 fixes:
Custom WSQIRequest subclass with 3 fixes/changes:
* Chunked Transfer Encoding (Django's behavior is broken)
* Skip ALLOWED_HOSTS validation for /health/ endpoints (see #140)
* Better error message for disallowed hosts
Note: used in all servers (in gunicorn through wsgi.py; in Django's runserver through WSGI_APPLICATION)
@@ -49,27 +80,33 @@ class CustomWSGIRequest(WSGIRequest):
We're leaking a bit of information here, but I don't think it's too much TBH -- especially in the light of ssl
certificates being specifically tied to the domain name.
"""
if self.path.startswith == "/health/":
# For /health/ endpoints, we skip the ALLOWED_HOSTS validation (see #140).
return self._get_raw_host()
# Import pushed down to make it absolutely clear we avoid circular importing/loading the wrong thing:
# copied from HttpRequest.get_host() in Django 4.2, with modifications.
host = self._get_raw_host()
# Allow variants of localhost if ALLOWED_HOSTS is empty and DEBUG=True.
from django.conf import settings
allowed_hosts = settings.ALLOWED_HOSTS
if settings.DEBUG and not allowed_hosts:
allowed_hosts = [".localhost", "127.0.0.1", "[::1]"]
try:
return super().get_host()
except DisallowedHost as e:
message = str(e)
domain, port = split_domain_port(host)
if domain and validate_host(domain, allowed_hosts):
return host
else:
if domain:
msg = allowed_hosts_error_message(domain, allowed_hosts)
if "ALLOWED_HOSTS" in message:
# The following 3 lines are copied from HttpRequest.get_host() in Django 4.2
allowed_hosts = settings.ALLOWED_HOSTS
if settings.DEBUG and not allowed_hosts:
allowed_hosts = [".localhost", "127.0.0.1", "[::1]"]
message = message[:-1 * len(".")]
message += ", which is currently set to %s." % repr(allowed_hosts)
# from None, because our DisallowedHost is so directly caused by super()'s DisallowedHost that cause and
# effect are the same, i.e. cause must be hidden from the stacktrace for the sake of clarity.
raise DisallowedHost(message) from None
else:
msg = "Invalid HTTP_HOST header: %r." % host
msg += (
" The domain name provided is not valid according to RFC 1034/1035."
)
raise DisallowedHost(msg)
class CustomWSGIHandler(WSGIHandler):

View File

@@ -7,6 +7,10 @@ def _colon_port(port):
def build_dsn(base_url, project_id, public_key):
parts = urllib.parse.urlsplit(base_url)
assert parts.scheme in ("http", "https"), "The BASE_URL setting must be a valid URL (starting with http or https)."
assert parts.hostname, "The BASE_URL setting must be a valid URL. The hostname must be set."
return (f"{ parts.scheme }://{ public_key }@{ parts.hostname }{ _colon_port(parts.port) }" +
f"{ parts.path }/{ project_id }")

View File

@@ -2,6 +2,8 @@ services:
mysql:
image: mysql:latest
restart: unless-stopped
command : "--binlog_expire_logs_seconds=3600"
environment:
MYSQL_ROOT_PASSWORD: change_your_passwords_for_real_usage # TODO: Change this
MYSQL_DATABASE: bugsink
@@ -26,6 +28,8 @@ services:
CREATE_SUPERUSER: admin:admin # Change this (or remove it and execute 'createsuperuser' against the running container)
PORT: 8000
DATABASE_URL: mysql://root:change_your_passwords_for_real_usage@mysql:3306/bugsink
BEHIND_HTTPS_PROXY: "false" # Change this for setups behind a proxy w/ ssl enabled
BASE_URL: "http://localhost:8000"
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8000/\").raise_for_status()'"]
interval: 5s

View File

@@ -1,11 +1,17 @@
import json
from django.utils.html import escape, mark_safe
from django.contrib import admin
from django.views.decorators.csrf import csrf_protect
from django.utils.decorators import method_decorator
import json
from bugsink.transaction import immediate_atomic
from projects.admin import ProjectFilter
from .models import Event
csrf_protect_m = method_decorator(csrf_protect)
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
@@ -90,3 +96,28 @@ class EventAdmin(admin.ModelAdmin):
def on_site(self, obj):
return mark_safe('<a href="' + escape(obj.get_absolute_url()) + '">View</a>')
def get_deleted_objects(self, objs, request):
to_delete = list(objs) + ["...all its related objects... (delayed)"]
model_count = {
Event: len(objs),
}
perms_needed = set()
protected = []
return to_delete, model_count, perms_needed, protected
def delete_queryset(self, request, queryset):
# NOTE: not the most efficient; it will do for a first version.
with immediate_atomic():
for obj in queryset:
obj.delete_deferred()
def delete_model(self, request, obj):
with immediate_atomic():
obj.delete_deferred()
@csrf_protect_m
def delete_view(self, request, object_id, extra_context=None):
# the superclass version, but with the transaction.atomic context manager commented out (we do this ourselves)
# with transaction.atomic(using=router.db_for_write(self.model)):
return self._delete_view(request, object_id, extra_context)

View File

@@ -43,11 +43,24 @@ def create_event(project=None, issue=None, timestamp=None, event_data=None):
)
def create_event_data():
def create_event_data(exception_type=None):
# create minimal event data that is valid as per from_json()
return {
result = {
"event_id": uuid.uuid4().hex,
"timestamp": timezone.now().isoformat(),
"platform": "python",
}
if exception_type is not None:
# allow for a specific exception type to get unique groupers/issues
result["exception"] = {
"values": [
{
"type": exception_type,
"value": "This is a test exception",
}
]
}
return result

View File

@@ -16,15 +16,31 @@ class Command(BaseCommand):
# are practice and theory the same. In practice, they are not.
def add_arguments(self, parser):
parser.add_argument('storage_name', type=str, help='The name of the storage to clean up')
storage_names = get_settings().EVENT_STORAGES.keys()
available_storages = ", ".join(storage_names)
if storage_names:
help_text = f'Name of the storage to clean up (one of: {available_storages})'
else:
help_text = 'Name of the storage to clean up. You have not configured any event storages, so storage ' \
'cleanup is not possible.'
parser.add_argument('storage_name', type=str, help=help_text)
def handle(self, *args, **options):
self.stopped = False
signal.signal(signal.SIGINT, self.handle_sigint)
storage_names = ",".join(get_settings().EVENT_STORAGES.keys())
storage_names = get_settings().EVENT_STORAGES.keys()
available_storages = ", ".join(storage_names)
if options['storage_name'] not in storage_names:
print(f"Storage name {options['storage_name']} not found. Available storage names: {storage_names}")
if not storage_names:
print("Storage name {options['storage_name']} not found because you have not configured any event "
"storage at all so cleanup of event-storage doesn't really make sense.")
sys.exit(1)
print(f"Storage name {options['storage_name']} not found. Available storage names: {available_storages}")
sys.exit(1)
storage = get_storage(options['storage_name'])

View File

@@ -11,6 +11,9 @@ from bugsink.transaction import immediate_atomic
from bugsink.timed_sqlite_backend.base import allow_long_running_queries
from bugsink.moreiterutils import batched
from projects.tasks import delete_project_deps
from issues.tasks import delete_issue_deps
class DryRunException(Exception):
# transaction.rollback doesn't work in atomic blocks; a poor man's substitute is to just raise something specific.
@@ -23,7 +26,7 @@ def _delete_for_missing_fk(clazz, field_name):
## Dangling FKs:
Non-existing objects may come into being when people muddle in the database directly with foreign key checks turned
off (note that fk checks are turned off by default in SQLite for backwards compatibility reasons).
off (note that fk checks are turned off by default in sqlite's CLI for backwards compatibility reasons).
In the future it's further possible that there will be pieces the actual Bugsink code where FK-checks are turned off
temporarily (e.g. when deleting a project with very many related objects). (In March 2025 there was no such code
@@ -76,6 +79,8 @@ def make_consistent():
_delete_for_missing_fk(Release, 'project')
_delete_for_missing_fk(EventTag, 'issue') # See #132 for the ordering of this statement
_delete_for_missing_fk(Event, 'project')
_delete_for_missing_fk(Event, 'issue')
@@ -138,7 +143,7 @@ class Command(BaseCommand):
# In theory, this command should not be required, because Bugsink _should_ leave itself in a consistent state after
# every operation. However, in practice Bugsink may not always do as promised, people reach into the database for
# whatever reason, or things go out of whack during development.
# whatever reason, things go out of whack during development, or a crash of snappea leaves half-finished work.
def add_arguments(self, parser):
parser.add_argument('--dry-run', action='store_true', help="Roll back all changes after making them.")
@@ -151,5 +156,32 @@ class Command(BaseCommand):
make_consistent()
if options['dry_run']:
raise DryRunException("Dry run requested; rolling back changes.")
if not options['dry_run']:
# for is_deleted objects, we enqueue deletion.
#
# such objects may remain dangling forever because the "enqueue if work remains" is not robust for
# snappea-shutdown (snappea will not detect remaining work on-restartup).
#
# doing this as an "enqueue work in snappea" solution is somewhat unsatisfying, because:
# * it means more stuff will happen _after_ make_consistent is done running;
# * it means that inconstencies created during the deferred process are not made consistent
# * because we cannot detect what snappea is currently doing (at least not without hacks) we might
# doubly-enqueue (it will still work, but one process will have a NoSuchObjectError at the end)
#
# I still picked this, because the alternative of doing it inline has its own problems:
# * we may easily exhaust the stack when calling this on lots of objects; bigger batches isn't a
# solution for this because it has its own problems (we don't have batches for nothing).
# * our tasks have "immediate atomic" on the inside, not a happy marriage with the approach here
# (including dry-run)
for obj in Project.objects.filter(is_deleted=True):
print("Enqueuing deletion of project dependencies for %s" % obj)
delete_project_deps.delay(str(obj.pk))
for obj in Issue.objects.filter(is_deleted=True):
print("Enqueuing deletion of issue dependencies for %s" % obj)
delete_issue_deps.delay(str(obj.project_id), str(obj.pk))
except DryRunException:
print("Changes have been rolled back (dry-run)")

View File

@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
('event_id', models.UUIDField(editable=False, help_text='As per the sent data')),
('data', models.TextField()),
('timestamp', models.DateTimeField(db_index=True)),
('platform', models.CharField(choices=[('as3', 'As3'), ('c', 'C'), ('cfml', 'Cfml'), ('cocoa', 'Cocoa'), ('csharp', 'Csharp'), ('elixir', 'Elixir'), ('haskell', 'Haskell'), ('go', 'Go'), ('groovy', 'Groovy'), ('java', 'Java'), ('javascript', 'Javascript'), ('native', 'Native'), ('node', 'Node'), ('objc', 'Objc'), ('other', 'Other'), ('perl', 'Perl'), ('php', 'Php'), ('python', 'Python'), ('ruby', 'Ruby')], max_length=64)),
('platform', models.CharField(max_length=64)),
('level', models.CharField(blank=True, choices=[('fatal', 'Fatal'), ('error', 'Error'), ('warning', 'Warning'), ('info', 'Info'), ('debug', 'Debug')], max_length=7)),
('logger', models.CharField(blank=True, default='', max_length=64)),
('transaction', models.CharField(blank=True, default='', max_length=200)),

View File

@@ -0,0 +1,34 @@
# Generated by Django 4.2.21 on 2025-07-03 08:30
from django.db import migrations
def remove_events_with_null_fks(apps, schema_editor):
# Up until now, we have various models w/ .issue=FK(null=True, on_delete=models.SET_NULL)
# Although it is "not expected" in the interface, issue-deletion would have led to those
# objects with a null issue. We're about to change that to .issue=FK(null=False, ...) which
# would crash if we don't remove those objects first. Object-removal is "fine" though, because
# as per the meaning of the SET_NULL, these objects were "dangling" anyway.
Event = apps.get_model("events", "Event")
Event.objects.filter(issue__isnull=True).delete()
# overcomplete b/c .issue would imply this, done anyway in the name of "defensive programming"
Event.objects.filter(grouping__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
("events", "0019_event_storage_backend"),
# "in principle" order shouldn't matter, because the various objects that are being deleted here are all "fully
# contained" by the .issue; to be safe, however, we depend on the below, because of Grouping.objects.delete()
# (which would set Event.grouping=NULL, which the present migration takes into account).
("issues", "0020_remove_objects_with_null_issue"),
]
operations = [
migrations.RunPython(remove_events_with_null_fks, reverse_code=migrations.RunPython.noop),
]

View File

@@ -0,0 +1,27 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("issues", "0021_alter_do_nothing"),
("events", "0020_remove_events_with_null_issue_or_grouping"),
]
operations = [
migrations.AlterField(
model_name="event",
name="grouping",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING, to="issues.grouping"
),
),
migrations.AlterField(
model_name="event",
name="issue",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING, to="issues.issue"
),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
# Django came up with 0014, whatever the reason, I'm sure that 0013 is at least required (as per comments there)
("projects", "0014_alter_projectmembership_project"),
("events", "0021_alter_do_nothing"),
]
operations = [
migrations.AlterField(
model_name="event",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project"
),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0022_alter_event_project'),
]
operations = [
migrations.AddField(
model_name='event',
name='remote_addr',
field=models.GenericIPAddressField(blank=True, default=None, null=True),
),
]

View File

@@ -8,33 +8,14 @@ from django.utils.functional import cached_property
from projects.models import Project
from compat.timestamp import parse_timestamp
from bugsink.transaction import delay_on_commit
from issues.utils import get_title_for_exception_type_and_value
from .retention import get_random_irrelevance
from .storage_registry import get_write_storage, get_storage
class Platform(models.TextChoices):
AS3 = "as3"
C = "c"
CFML = "cfml"
COCOA = "cocoa"
CSHARP = "csharp"
ELIXIR = "elixir"
HASKELL = "haskell"
GO = "go"
GROOVY = "groovy"
JAVA = "java"
JAVASCRIPT = "javascript"
NATIVE = "native"
NODE = "node"
OBJC = "objc"
OTHER = "other"
PERL = "perl"
PHP = "php"
PYTHON = "python"
RUBY = "ruby"
from .tasks import delete_event_deps
class Level(models.TextChoices):
@@ -71,12 +52,10 @@ class Event(models.Model):
ingested_at = models.DateTimeField(blank=False, null=False)
digested_at = models.DateTimeField(db_index=True, blank=False, null=False)
remote_addr = models.GenericIPAddressField(blank=True, null=True, default=None)
# not actually expected to be null, but we want to be able to delete issues without deleting events (cleanup later)
issue = models.ForeignKey("issues.Issue", blank=False, null=True, on_delete=models.SET_NULL)
# not actually expected to be null
grouping = models.ForeignKey("issues.Grouping", blank=False, null=True, on_delete=models.SET_NULL)
issue = models.ForeignKey("issues.Issue", blank=False, null=False, on_delete=models.DO_NOTHING)
grouping = models.ForeignKey("issues.Grouping", blank=False, null=False, on_delete=models.DO_NOTHING)
# The docs say:
# > Required. Hexadecimal string representing a uuid4 value. The length is exactly 32 characters. Dashes are not
@@ -85,7 +64,7 @@ class Event(models.Model):
# uuid4 clientside". In any case, we just rely on the envelope's event_id (required per the envelope spec).
# Not a primary key: events may be duplicated across projects
event_id = models.UUIDField(primary_key=False, null=False, editable=False, help_text="As per the sent data")
project = models.ForeignKey(Project, blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later'
project = models.ForeignKey(Project, blank=False, null=False, on_delete=models.DO_NOTHING)
data = models.TextField(blank=False, null=False)
@@ -93,8 +72,10 @@ class Event(models.Model):
# > a numeric (integer or float) value representing the number of seconds that have elapsed since the Unix epoch.
timestamp = models.DateTimeField(db_index=True, blank=False, null=False)
# > A string representing the platform the SDK is submitting from. [..] Acceptable values are [as defined below]
platform = models.CharField(max_length=64, blank=False, null=False, choices=Platform.choices)
# > A string representing the platform the SDK is submitting from. [..]
# (the list of supported platforms is ~700 items long, and since we don't actually depend on this value to be any
# item from that list, we don't force it to be one of them)
platform = models.CharField(max_length=64, blank=False, null=False)
# > ### Optional Attributes
@@ -257,6 +238,11 @@ class Event(models.Model):
debug_info=event_metadata["debug_info"][:255],
# just getting from the dict would be more precise, since we always add this info, but doing the .get()
# allows for backwards compatability (digesting events for which the info was not added on-ingest) so
# we'll take the defensive approach "for now" (until most everyone is on >= 1.7.4)
remote_addr=event_metadata.get("remote_addr"),
digest_order=digest_order,
irrelevance_for_retention=irrelevance_for_retention,
@@ -285,3 +271,13 @@ class Event(models.Model):
return list(
self.tags.all().select_related("value", "value__key").order_by("value__key__key")
)
def delete_deferred(self):
"""Schedules deletion of all related objects"""
# NOTE: for such a small closure, I couldn't be bothered to have an .is_deleted field and deal with it. (the
# idea being that the deletion will be relatively quick anyway). We still need "something" though, since we've
# set DO_NOTHING everywhere. An alternative would be the "full inline", i.e. delete everything right in the
# request w/o any delay. That diverges even more from the approach for Issue/Project, making such things a
# "design decision needed". Maybe if we get more `delete_deferred` impls. we'll have a bit more info to figure
# out if we can harmonize on (e.g.) 2 approaches.
delay_on_commit(delete_event_deps, str(self.project_id), str(self.id))

View File

@@ -376,7 +376,7 @@ def evict_for_epoch_and_irrelevance(project, max_epoch, max_irrelevance, max_eve
Event.objects.filter(pk__in=pks_to_delete).exclude(storage_backend=None)
.values_list("id", "storage_backend")
)
issue_deletions = {
deletions_per_issue = {
d['issue_id']: d['count'] for d in
Event.objects.filter(pk__in=pks_to_delete).values("issue_id").annotate(count=Count("issue_id"))}
@@ -387,9 +387,9 @@ def evict_for_epoch_and_irrelevance(project, max_epoch, max_irrelevance, max_eve
nr_of_deletions = Event.objects.filter(pk__in=pks_to_delete).delete()[1].get("events.Event", 0)
else:
nr_of_deletions = 0
issue_deletions = {}
deletions_per_issue = {}
return EvictionCounts(nr_of_deletions, issue_deletions)
return EvictionCounts(nr_of_deletions, deletions_per_issue)
def cleanup_events_on_storage(todos):

53
events/tasks.py Normal file
View File

@@ -0,0 +1,53 @@
from snappea.decorators import shared_task
from bugsink.utils import get_model_topography, delete_deps_with_budget
from bugsink.transaction import immediate_atomic, delay_on_commit
@shared_task
def delete_event_deps(project_id, event_id):
from .models import Event # avoid circular import
with immediate_atomic():
# matches what we do in events/retention.py (and for which argumentation exists); in practive I have seen _much_
# faster deletion times (in the order of .03s per task on my local laptop) when using a budget of 500, _but_
# it's not a given those were for "expensive objects" (e.g. events); and I'd rather err on the side of caution
# (worst case we have a bit of inefficiency; in any case this avoids hogging the global write lock / timeouts).
budget = 500
num_deleted = 0
# NOTE: for this delete_x_deps, we didn't bother optimizing the topography graph (the dependency-graph of a
# single event is believed to be small enough to not warrent further optimization).
dep_graph = get_model_topography()
for model_for_recursion, fk_name_for_recursion in dep_graph["events.Event"]:
this_num_deleted = delete_deps_with_budget(
project_id,
model_for_recursion,
fk_name_for_recursion,
[event_id],
budget - num_deleted,
dep_graph,
is_for_project=False,
)
num_deleted += this_num_deleted
if num_deleted >= budget:
delay_on_commit(delete_event_deps, project_id, event_id)
return
if budget - num_deleted <= 0:
# no more budget for the self-delete.
delay_on_commit(delete_event_deps, project_id, event_id)
else:
# final step: delete the event itself
issue = Event.objects.get(pk=event_id).issue
Event.objects.filter(pk=event_id).delete()
# manual (outside of delete_deps_with_budget) b/c the special-case in that function is (ATM) specific to
# project (it was built around Issue-deletion initially, so Issue outliving the event-deletion was not
# part of that functionality). we might refactor this at some point.
issue.stored_event_count -= 1
issue.save(update_fields=["stored_event_count"])

View File

@@ -9,8 +9,7 @@ from django.utils import timezone
from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase
from projects.models import Project, ProjectMembership
from issues.models import Issue
from issues.factories import denormalized_issue_fields
from issues.factories import get_or_create_issue
from .factories import create_event
from .retention import (
@@ -28,7 +27,7 @@ class ViewTests(TransactionTestCase):
self.user = User.objects.create_user(username='test', password='test')
self.project = Project.objects.create()
ProjectMembership.objects.create(project=self.project, user=self.user)
self.issue = Issue.objects.create(project=self.project, **denormalized_issue_fields())
self.issue, _ = get_or_create_issue(project=self.project)
self.event = create_event(self.project, self.issue)
self.client.force_login(self.user)
@@ -154,7 +153,7 @@ class RetentionTestCase(DjangoTestCase):
digested_at = timezone.now()
self.project = Project.objects.create(retention_max_event_count=5)
self.issue = Issue.objects.create(project=self.project, **denormalized_issue_fields())
self.issue, _ = get_or_create_issue(project=self.project)
for digest_order in range(1, 7):
project_stored_event_count += 1 # +1 pre-create, as in the ingestion view
@@ -180,7 +179,7 @@ class RetentionTestCase(DjangoTestCase):
project_stored_event_count = 0
self.project = Project.objects.create(retention_max_event_count=999)
self.issue = Issue.objects.create(project=self.project, **denormalized_issue_fields())
self.issue, _ = get_or_create_issue(project=self.project)
current_timestamp = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)

View File

@@ -1,8 +1,16 @@
from os.path import basename
from datetime import datetime, timezone
from uuid import UUID
import json
import sourcemap
from issues.utils import get_values
from bugsink.transaction import delay_on_commit
from compat.timestamp import format_timestamp
from files.models import FileMetadata
from files.tasks import record_file_accesses
# Dijkstra, Sourcemaps and Python lists start at 0, but editors and our UI show lines starting at 1.
@@ -104,17 +112,20 @@ def apply_sourcemaps(event_data):
return
debug_id_for_filename = {
image["code_file"]: image["debug_id"]
image["code_file"]: UUID(image["debug_id"])
for image in images
if "debug_id" in image and "code_file" in image and image["type"] == "sourcemap"
}
metadata_obj_lookup = {
str(metadata_obj.debug_id): metadata_obj
metadata_obj.debug_id: metadata_obj
for metadata_obj in FileMetadata.objects.filter(
debug_id__in=debug_id_for_filename.values(), file_type="source_map").select_related("file")
}
metadata_ids = [metadata_obj.id for metadata_obj in metadata_obj_lookup.values()]
delay_on_commit(record_file_accesses, metadata_ids, format_timestamp(datetime.now(timezone.utc)))
filenames_with_metas = [
(filename, metadata_obj_lookup[debug_id])
for (filename, debug_id) in debug_id_for_filename.items()
@@ -129,26 +140,60 @@ def apply_sourcemaps(event_data):
source_for_filename = {}
for filename, meta in filenames_with_metas:
sm_data = json.loads(_postgres_fix(meta.file.data))
if "sourcesContent" not in sm_data or len(sm_data["sourcesContent"]) != 1:
# our assumption is: 1 sourcemap, 1 source. The fact that both "sources" (a list of filenames) and
# "sourcesContent" are lists seems to indicate that this assumption does not generally hold. But it not
# holding does not play well with the id of debug_id, I think?
continue
source_for_filename[filename] = sm_data["sourcesContent"][0].splitlines()
sources = sm_data.get("sources", [])
sources_content = sm_data.get("sourcesContent", [])
for (source_file_name, source_file) in zip(sources, sources_content):
source_for_filename[source_file_name] = source_file.splitlines()
for exception in get_values(event_data.get("exception", {})):
for frame in exception.get("stacktrace", {}).get("frames", []):
# NOTE: try/except in the loop would allow us to selectively skip frames that we fail to process
if frame.get("filename") in sourcemap_for_filename and frame["filename"] in source_for_filename:
if frame.get("filename") in sourcemap_for_filename:
sm = sourcemap_for_filename[frame["filename"]]
lines = source_for_filename[frame["filename"]]
token = sm.lookup(frame["lineno"] + FROM_DISPLAY, frame["colno"])
frame["pre_context"] = lines[max(0, token.src_line - 5):token.src_line]
frame["context_line"] = lines[token.src_line]
frame["post_context"] = lines[token.src_line + 1:token.src_line + 5]
frame["lineno"] = token.src_line + TO_DISPLAY
# frame["colno"] = token.src_col + TO_DISPLAY not actually used
if token.src in source_for_filename:
lines = source_for_filename[token.src]
frame["pre_context"] = lines[max(0, token.src_line - 5):token.src_line]
frame["context_line"] = lines[token.src_line]
frame["post_context"] = lines[token.src_line + 1:token.src_line + 5]
frame["lineno"] = token.src_line + TO_DISPLAY
frame['filename'] = token.src
frame['function'] = token.name
# frame["colno"] = token.src_col + TO_DISPLAY not actually used
elif frame.get("filename") in debug_id_for_filename:
# The event_data reports that a debug_id is available for this filename, but we don't have it; this
# could be because the sourcemap was not uploaded. We want to show the debug_id in the stacktrace as
# a hint to the user that they should upload the sourcemap.
frame["debug_id"] = str(debug_id_for_filename[frame["filename"]])
def get_sourcemap_images(event_data):
# NOTE: butchered copy/paste of apply_sourcemaps; refactoring for DRY is a TODO
images = event_data.get("debug_meta", {}).get("images", [])
if not images:
return []
debug_id_for_filename = {
image["code_file"]: UUID(image["debug_id"])
for image in images
if "debug_id" in image and "code_file" in image and image["type"] == "sourcemap"
}
metadata_obj_lookup = {
metadata_obj.debug_id: metadata_obj
for metadata_obj in FileMetadata.objects.filter(
debug_id__in=debug_id_for_filename.values(), file_type="source_map").select_related("file")
}
return [
(basename(filename),
f"{debug_id} " + (" (uploaded)" if debug_id in metadata_obj_lookup else " (not uploaded)"))
for filename, debug_id in debug_id_for_filename.items()
]

View File

@@ -7,14 +7,14 @@ from .models import Chunk, File, FileMetadata
@admin.register(Chunk)
class ChunkAdmin(admin.ModelAdmin):
list_display = ('checksum', 'size')
list_display = ('checksum', 'size', 'created_at')
search_fields = ('checksum',)
readonly_fields = ('data',)
@admin.register(File)
class FileAdmin(admin.ModelAdmin):
list_display = ('filename', 'checksum', 'size', 'download_link')
list_display = ('filename', 'checksum', 'size', 'download_link', 'created_at', 'accessed_at')
search_fields = ('checksum',)
readonly_fields = ('data', 'download_link')
@@ -27,5 +27,6 @@ class FileAdmin(admin.ModelAdmin):
@admin.register(FileMetadata)
class FileMetadataAdmin(admin.ModelAdmin):
list_display = ('debug_id', 'file_type', 'file')
list_display = ('debug_id', 'file_type', 'file', 'created_at')
search_fields = ('file__checksum', 'debug_id', 'file_type')
readonly_fields = ('file', 'debug_id', 'file_type', 'data', 'created_at')

View File

View File

View File

@@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from files.tasks import vacuum_files
class Command(BaseCommand):
help = "Kick off (sourcemaps-)files cleanup by vacuuming old entries."
def handle(self, *args, **options):
vacuum_files.delay()
self.stdout.write("Called vacuum_files.delay(); the task will run in the background (snapea).")

View File

@@ -0,0 +1,44 @@
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("files", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="chunk",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, db_index=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="file",
name="accessed_at",
field=models.DateTimeField(
auto_now_add=True, db_index=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="file",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, db_index=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="filemetadata",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, db_index=True, default=django.utils.timezone.now
),
preserve_default=False,
),
]

View File

@@ -5,6 +5,7 @@ class Chunk(models.Model):
checksum = models.CharField(max_length=40, unique=True) # unique implies index, which we also use for lookups
size = models.PositiveIntegerField()
data = models.BinaryField(null=False) # as with Events, we can "eventually" move this out of the database
created_at = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
def __str__(self):
return self.checksum
@@ -23,6 +24,8 @@ class File(models.Model):
size = models.PositiveIntegerField()
data = models.BinaryField(null=False) # as with Events, we can "eventually" move this out of the database
created_at = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
accessed_at = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
def __str__(self):
return self.filename
@@ -36,6 +39,7 @@ class FileMetadata(models.Model):
debug_id = models.UUIDField(max_length=40, null=True, blank=True)
file_type = models.CharField(max_length=255, null=True, blank=True)
data = models.TextField() # we just dump the rest in here; let's see how much we really need.
created_at = models.DateTimeField(auto_now_add=True, editable=False, db_index=True)
def __str__(self):
# somewhat useless when debug_id is None; but that's not the case we care about ATM

View File

@@ -1,14 +1,31 @@
import re
import logging
from datetime import timedelta
from zipfile import ZipFile
import json
from hashlib import sha1
from io import BytesIO
from os.path import basename
from django.utils import timezone
from compat.timestamp import parse_timestamp
from snappea.decorators import shared_task
from bugsink.transaction import immediate_atomic
from bugsink.transaction import immediate_atomic, delay_on_commit
from bugsink.app_settings import get_settings
from .models import Chunk, File, FileMetadata
logger = logging.getLogger("bugsink.api")
# "In the wild", we have run into non-unique debug IDs (one in code, one in comment-at-bottom). This regex matches a
# known pattern for "one in code", such that we can at least warn if it's not the same at the actually reported one.
# See #157
IN_CODE_DEBUG_ID_REGEX = re.compile(
r'e\._sentryDebugIds\[.*?\]\s*=\s*["\']([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})["\']'
)
@shared_task
def assemble_artifact_bundle(bundle_checksum, chunk_checksums):
@@ -44,7 +61,16 @@ def assemble_artifact_bundle(bundle_checksum, chunk_checksums):
debug_id = manifest_entry.get("headers", {}).get("debug-id", None)
file_type = manifest_entry.get("type", None)
if debug_id is None or file_type is None:
# such records exist and we could store them, but we don't, since we don't have a purpose for them.
because = (
"it has neither Debug ID nor file-type" if debug_id is None and file_type is None else
"it has no Debug ID" if debug_id is None else "it has no file-type")
logger.warning(
"Uploaded file %s will be ignored by Bugsink because %s.",
filename,
because,
)
continue
FileMetadata.objects.get_or_create(
@@ -56,7 +82,21 @@ def assemble_artifact_bundle(bundle_checksum, chunk_checksums):
}
)
# NOTE we _could_ get rid of the file at this point (but we don't). Ties in to broader questions of retention.
# the in-code regexes show up in the _minified_ source only (the sourcemap's original source code will not
# have been "polluted" with it yet, since it's the original).
if file_type == "minified_source":
mismatches = set(IN_CODE_DEBUG_ID_REGEX.findall(file_data.decode("utf-8"))) - {debug_id}
if mismatches:
logger.warning(
"Uploaded file %s contains multiple Debug IDs. Uploaded as %s, but also found: %s.",
filename,
debug_id,
", ".join(sorted(mismatches)),
)
if not get_settings().KEEP_ARTIFACT_BUNDLES:
# delete the bundle file after processing, since we don't need it anymore.
bundle_file.delete()
def assemble_file(checksum, chunk_checksums, filename):
@@ -75,10 +115,67 @@ def assemble_file(checksum, chunk_checksums, filename):
if sha1(data).hexdigest() != checksum:
raise Exception("checksum mismatch")
return File.objects.get_or_create(
result = File.objects.get_or_create(
checksum=checksum,
defaults={
"size": len(data),
"data": data,
"filename": filename,
})
# the assumption here is: chunks are basically use-once, so we can delete them after use. "in theory" a chunk may
# be used in multiple files (which are still being assembled) but with chunksizes in the order of 1MiB, I'd say this
# is unlikely.
chunks.delete()
return result
@shared_task
def record_file_accesses(metadata_ids, accessed_at):
# implemented as a task to get around the fact that file-access happens in an otherwise read-only view (and the fact
# that the access happened is a write to the DB).
# a few thoughts on the context of "doing this as a task": [1] the expected througput is relatively low (UI) so the
# task overhead should be OK [2] it's not "absolutely criticial" to always record this (99% is enough) and [3] it's
# not related to the reading transaction _at all_ (all we need to record is the fact that it happened.
#
# thought on instead pulling it to the top of the UI's view: code-wise, it's annoying but doable (annoying b/c
# 'for_request_method' won't work anymore). But this would still make this key UI view depend on the write lock
# which is such a shame for responsiveness so we'll stick with task-based.
with immediate_atomic():
parsed_accessed_at = parse_timestamp(accessed_at)
# note: filtering on IDs comes with "robust for deletions" out-of-the-box (and: 2 queries only)
file_ids = FileMetadata.objects.filter(id__in=metadata_ids).values_list("file_id", flat=True)
File.objects.filter(id__in=file_ids).update(accessed_at=parsed_accessed_at)
@shared_task
def vacuum_files():
now = timezone.now()
with immediate_atomic():
# budget is not yet tuned; reasons for high values: we're dealing with "leaves in the model-dep-tree here";
# reasons for low values: deletion of files might just be expensive.
budget = 500
num_deleted = 0
for model, field_name, max_days in [
(Chunk, 'created_at', 1,), # 1 is already quite long... Chunks are used immediately, or not at all.
(File, 'accessed_at', 90),
# for FileMetadata we rely on cascading from File (which will always happen "eventually")
]:
while num_deleted < budget:
ids = (model.objects.filter(**{f"{field_name}__lt": now - timedelta(days=max_days)})[:budget].
values_list('id', flat=True))
if len(ids) == 0:
break
model.objects.filter(id__in=ids).delete()
num_deleted += len(ids)
if num_deleted == budget:
# budget exhausted but possibly more to delete, so we re-schedule the task
delay_on_commit(vacuum_files)

View File

@@ -1,3 +1,5 @@
from hashlib import sha1
from uuid import UUID
import json
import gzip
from io import BytesIO
@@ -10,6 +12,9 @@ from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase
from projects.models import Project, ProjectMembership
from events.models import Event
from bsmain.models import AuthToken
from bugsink.moreiterutils import batched
from .models import File, FileMetadata
User = get_user_model()
@@ -47,14 +52,41 @@ class FilesTests(TransactionTestCase):
self.assertEqual(401, response.status_code)
self.assertEqual({"error": "Invalid token"}, response.json())
def test_uuid_behavior_of_django(self):
# test to check Django is doing the thing of converting various UUID-like things on "both sides" before
# comparing. "this probably shouldn't be necessary" to test, but I'd rather have a test that proves it works
# than to have to reason about it. Context: https://github.com/bugsink/bugsink/issues/105
uuids = [
"12345678123456781234567812345678", # uuid_str_no_dashes
"12345678-1234-5678-1234-567812345678", # uuid_str_with_dashes
UUID("12345678-1234-5678-1234-567812345678"), # uuid_object
]
file = File.objects.create(size=0)
for create_with in uuids:
FileMetadata.objects.all().delete() # clean up before each test
FileMetadata.objects.create(
debug_id=create_with,
file_type="source_map",
file=file,
)
for test_with in uuids:
fms = FileMetadata.objects.filter(debug_id__in=[test_with])
self.assertEqual(1, fms.count())
def test_assemble_artifact_bundle(self):
SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples")
event_samples = [SAMPLES_DIR + fn for fn in ["/bugsink/uglifyjs-minified-sourcemaps-in-bundle.json"]]
event_samples = [SAMPLES_DIR + fn for fn in [
"/bugsink/uglifyjs-minified-sourcemaps-in-bundle.json",
"/bugsink/uglifyjs-minified-sourcemaps-in-bundle-multi-file.json",
]]
artifact_bundles = glob(SAMPLES_DIR + "/*/artifact_bundles/*.zip")
if len(artifact_bundles) == 0:
raise Exception(f"No artifact bundles found in {SAMPLES_DIR}; I insist on having some to test with.")
if len(artifact_bundles) != 2:
raise Exception(f"Not all artifact bundles found in {SAMPLES_DIR}; I insist on having some to test with.")
for filename in artifact_bundles:
with open(filename, 'rb') as f:
@@ -116,19 +148,73 @@ class FilesTests(TransactionTestCase):
200, response.status_code, "Error in %s: %s" % (
filename, response.content if response.status_code != 302 else response.url))
for event in Event.objects.all():
for event_id, key_phrase in [
("af4d4093e2d548bea61683abecb8ee95", '<span class="font-bold">captureException.js</span> in <span class="font-bold">foo</span> line <span class="font-bold">15</span>'), # noqa
("ed483af389554d9cac475049ed9f560f", '<span class="font-bold">captureException.js</span> in <span class="font-bold">foo</span> line <span class="font-bold">10</span>'), # noqa
]:
event = Event.objects.get(event_id=event_id)
url = f'/issues/issue/{ event.issue.id }/event/{ event.id }/'
try:
# we just check for a 200; this at least makes sure we have no failing template rendering
response = self.client.get(url)
self.assertEqual(
200, response.status_code, response.content if response.status_code != 302 else response.url)
# we could/should make this more general later; this is great for example nr.1:
key_phrase = '<span class="font-bold">captureException</span> line <span class="font-bold">15</span>'
self.assertTrue(key_phrase in response.content.decode('utf-8'))
except Exception as e:
# we want to know _which_ event failed, hence the raise-from-e here
raise AssertionError("Error rendering event %s" % event.debug_info) from e
raise AssertionError("Error rendering event %s" % event.event_id) from e
def test_assemble_artifact_bundle_small_chunks(self):
# Copy-paste of test_assemble_artifact_bundle, but checking _only_ that bundle assembly works with small chunks.
SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples")
filename = SAMPLES_DIR + "/bugsink/artifact_bundles/51a5a327666cf1d11e23adfd55c3becad27ae769.zip"
with open(filename, 'rb') as f:
all_data = f.read()
seen_checksums = []
for data in batched(all_data, len(all_data) // 10):
data = bytes(data)
checksum = sha1(data).hexdigest()
gzipped_file = BytesIO(gzip.compress(data))
gzipped_file.name = checksum
# 1. chunk-upload
response = self.client.post(
"/api/0/organizations/anyorg/chunk-upload/",
data={"file_gzip": gzipped_file},
headers=self.token_headers,
)
self.assertEqual(
200, response.status_code, "Error in %s: %s" % (
filename, response.content if response.status_code != 302 else response.url))
seen_checksums.append(checksum)
checksum = os.path.basename(filename).split(".")[0]
# 2. artifactbundle/assemble
data = {
"checksum": checksum,
"chunks": seen_checksums,
"projects": [
"unused_for_now"
]
}
response = self.client.post(
"/api/0/organizations/anyorg/artifactbundle/assemble/",
json.dumps(data),
content_type="application/json",
headers=self.token_headers,
)
self.assertEqual(
200, response.status_code, "Error in %s: %s" % (
filename, response.content if response.status_code != 302 else response.url))

View File

@@ -2,10 +2,12 @@ import json
from hashlib import sha1
from gzip import GzipFile
from io import BytesIO
import logging
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import user_passes_test
from django.http import Http404
from sentry.assemble import ChunkFileState
@@ -16,9 +18,12 @@ from bsmain.models import AuthToken
from .models import Chunk, File
from .tasks import assemble_artifact_bundle
logger = logging.getLogger("bugsink.api")
_KIBIBYTE = 1024
_MEBIBYTE = 1024 * _KIBIBYTE
_GIBIBYTE = 1024 * _MEBIBYTE
class NamedBytesIO(BytesIO):
@@ -39,26 +44,32 @@ def get_chunk_upload_settings(request, organization_slug):
# * https://github.com/getsentry/sentry/pull/29347
url = get_settings().BASE_URL + "/api/0/organizations/" + organization_slug + "/chunk-upload/"
# Our "chunk_upload" is chunked in name only; i.e. we only "speak chunked" for the purpose of API-compatability with
# sentry-cli, but we provide params here such that that cli will only send a single chunk.
return JsonResponse({
"url": url,
# For now, staying close to the default MAX_ENVELOPE_COMPRESSED_SIZE, which is 20MiB;
# I _think_ I saw a note somewhere on (one of) these values having to be a power of 2; hence 32 here.
#
# When implementing uploading, it was done to support sourcemaps. It seems that over at Sentry, the reason they
# went so complicated in the first place was to enable DIF support (hunderds of MiB regularly).
"chunkSize": 32 * _MEBIBYTE,
"maxRequestSize": 32 * _MEBIBYTE,
# We pick a "somewhat arbitrary" value between 1MiB and 16MiB to balance between "works reliably" and "lower
# overhead", erring on the "works reliably" side of that spectrum. There's really no lower bound technically,
# I've played with 32-byte requests.
# note: sentry-cli <= v2.39.1 requires a power of 2 here.
# chunkSize == maxRequestSize per the comments on `chunksPerRequest: 1`.
"chunkSize": 2 * _MEBIBYTE,
"maxRequestSize": 2 * _MEBIBYTE,
# I didn't check the supposed relationship between maxRequestSize and maxFileSize, but assume something similar
# to what happens w/ envelopes; hence harmonizing with MAX_ENVELOPE_SIZE (and rounding up to a power of 2) here
"maxFileSize": 128 * _MEBIBYTE,
# The limit here is _actually storing this_. For now "just picking a high limit" assuming that we'll have decent
# storage (#151) for the files eventually.
"maxFileSize": 2 * _GIBIBYTE,
# force single-chunk by setting these to 1.
# In our current setup increasing concurrency doesn't help (single-writer architecture) while coming at the cost
# of potential reliability issues. Current codebase has works just fine with it _in principle_ (tested by
# setting concurrency=10, chunkSize=32, maxRequestSize=32 and adding a sleep(random(..)) in chunk_upload (right
# before return, and seeing that sentry-cli fires a bunch of things in parallel and artifact_bundle_assemble as
# a final step.
"concurrency": 1,
# There _may_ be good reasons to support multiple chunks per request, but I haven't found a reason to
# distinguish between chunkSize and maxRequestSize yet, so I'd rather keep them synced for easier reasoning.
# Current codebase has been observed to work just fine with it though (tested w/ chunkSize=32 and
# chunksPerRequest=100 and seeing sentry-cli do a single request with many small chunks).
"chunksPerRequest": 1,
"hashAlgorithm": "sha1",
@@ -193,3 +204,40 @@ def download_file(request, checksum):
response = HttpResponse(file.data, content_type="application/octet-stream")
response["Content-Disposition"] = f"attachment; filename={file.filename}"
return response
@csrf_exempt
def api_catch_all(request, subpath):
if not get_settings().API_LOG_UNIMPLEMENTED_CALLS:
raise Http404("Unimplemented API endpoint: /api/" + subpath)
lines = [
"Unimplemented API usage:",
f" Path: /api/{subpath}",
f" Method: {request.method}",
]
if request.GET:
lines.append(f" GET: {request.GET.dict()}")
if request.POST:
lines.append(f" POST: {request.POST.dict()}")
body = request.body
if body:
try:
decoded = body.decode("utf-8", errors="replace").strip()
lines.append(" Body:")
lines.append(f" {decoded[:500]}")
try:
parsed = json.loads(decoded)
pretty = json.dumps(parsed, indent=2)[:10_000]
lines.append(" JSON body:")
lines.extend(f" {line}" for line in pretty.splitlines())
except json.JSONDecodeError:
pass
except Exception as e:
lines.append(f" Body: <decode error: {e}>")
logger.info("\n".join(lines))
raise Http404("Unimplemented API endpoint: /api/" + subpath)

4
gunicorn.docker.conf.py Normal file
View File

@@ -0,0 +1,4 @@
# gunicorn config file for Docker deployments
import multiprocessing
workers = min(multiprocessing.cpu_count(), 4)

View File

@@ -296,7 +296,7 @@ class IngestViewTestCase(TransactionTestCase):
SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples")
event_samples = glob(SAMPLES_DIR + "/*/*.json")
event_samples = glob(SAMPLES_DIR + "/sentry/mobile1-xen.json") # pick a fixed one for reproducibility
known_broken = [SAMPLES_DIR + "/" + s.strip() for s in _readlines(SAMPLES_DIR + "/KNOWN-BROKEN")]
if len(event_samples) == 0:
@@ -436,7 +436,8 @@ class IngestViewTestCase(TransactionTestCase):
SAMPLES_DIR = os.getenv("SAMPLES_DIR", "../event-samples")
event_samples = glob(SAMPLES_DIR + "/*/*.json")
event_samples = glob(SAMPLES_DIR + "/sentry/mobile1-xen.json") # this one has 'exception.values[0].type'
known_broken = [SAMPLES_DIR + "/" + s.strip() for s in _readlines(SAMPLES_DIR + "/KNOWN-BROKEN")]
if len(event_samples) == 0:

View File

@@ -1,3 +1,4 @@
import hashlib
import os
import logging
import io
@@ -130,7 +131,7 @@ class BaseIngestAPIView(View):
@classmethod
def get_project(cls, project_pk, sentry_key):
try:
return Project.objects.get(pk=project_pk, sentry_key=sentry_key)
return Project.objects.get(pk=project_pk, sentry_key=sentry_key, is_deleted=False)
except Project.DoesNotExist:
# We don't distinguish between "project not found" and "key incorrect"; there's no real value in that from
# the user perspective (they deal in dsns). Additional advantage: no need to do constant-time-comp on
@@ -170,11 +171,17 @@ class BaseIngestAPIView(View):
# Meta means: not part of the event data. Basically: information that is available at the time of ingestion, and
# that must be passed to digest() in a serializable form.
debug_info = request.META.get("HTTP_X_BUGSINK_DEBUGINFO", "")
# .get(..) -- don't want to crash on this and it's non-trivial to find a source that tells me with certainty
# that the REMOTE_ADDR is always in request.META (it probably is in practice)
remote_addr = request.META.get("REMOTE_ADDR")
return {
"event_id": event_id,
"project_id": project.id,
"ingested_at": format_timestamp(ingested_at),
"debug_info": debug_info,
"remote_addr": remote_addr,
}
@classmethod
@@ -250,7 +257,12 @@ class BaseIngestAPIView(View):
ingested_at = parse_timestamp(event_metadata["ingested_at"])
digested_at = datetime.now(timezone.utc) if digested_at is None else digested_at # explicit passing: test only
project = Project.objects.get(pk=event_metadata["project_id"])
try:
project = Project.objects.get(pk=event_metadata["project_id"], is_deleted=False)
except Project.DoesNotExist:
# we may get here if the project was deleted after the event was ingested, but before it was digested
# (covers both "deletion in progress (is_deleted=True)" and "fully deleted").
return
if not cls.count_project_periods_and_act_on_it(project, digested_at):
return # if over-quota: just return (any cleanup is done calling-side)
@@ -269,7 +281,19 @@ class BaseIngestAPIView(View):
grouping_key = get_issue_grouper_for_data(event_data, calculated_type, calculated_value)
if not Grouping.objects.filter(project_id=event_metadata["project_id"], grouping_key=grouping_key).exists():
try:
grouping = Grouping.objects.get(
project_id=event_metadata["project_id"], grouping_key=grouping_key,
grouping_key_hash=hashlib.sha256(grouping_key.encode()).hexdigest())
issue = grouping.issue
issue_created = False
# update the denormalized fields
issue.last_seen = ingested_at
issue.digested_event_count += 1
except Grouping.DoesNotExist:
# we don't have Project.issue_count here ('premature optimization') so we just do an aggregate instead.
max_current = Issue.objects.filter(project_id=event_metadata["project_id"]).aggregate(
Max("digest_order"))["digest_order__max"]
@@ -291,18 +315,10 @@ class BaseIngestAPIView(View):
grouping = Grouping.objects.create(
project_id=event_metadata["project_id"],
grouping_key=grouping_key,
grouping_key_hash=hashlib.sha256(grouping_key.encode()).hexdigest(),
issue=issue,
)
else:
grouping = Grouping.objects.get(project_id=event_metadata["project_id"], grouping_key=grouping_key)
issue = grouping.issue
issue_created = False
# update the denormalized fields
issue.last_seen = ingested_at
issue.digested_event_count += 1
# +1 because we're about to add one event.
project_stored_event_count = project.stored_event_count + 1
@@ -355,6 +371,7 @@ class BaseIngestAPIView(View):
if issue_created:
TurningPoint.objects.create(
project=project,
issue=issue, triggering_event=event, timestamp=ingested_at,
kind=TurningPointKind.FIRST_SEEN)
event.never_evict = True
@@ -366,6 +383,7 @@ class BaseIngestAPIView(View):
# new issues cannot be regressions by definition, hence this is in the 'else' branch
if issue_is_regression(issue, event.release):
TurningPoint.objects.create(
project=project,
issue=issue, triggering_event=event, timestamp=ingested_at,
kind=TurningPointKind.REGRESSED)
event.never_evict = True

View File

@@ -1,8 +1,14 @@
from django.contrib import admin
from bugsink.transaction import immediate_atomic
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
from .models import Issue, Grouping, TurningPoint
from .forms import IssueAdminForm
csrf_protect_m = method_decorator(csrf_protect)
class GroupingInline(admin.TabularInline):
model = Grouping
@@ -79,3 +85,28 @@ class IssueAdmin(admin.ModelAdmin):
'digested_event_count',
'stored_event_count',
]
def get_deleted_objects(self, objs, request):
to_delete = list(objs) + ["...all its related objects... (delayed)"]
model_count = {
Issue: len(objs),
}
perms_needed = set()
protected = []
return to_delete, model_count, perms_needed, protected
def delete_queryset(self, request, queryset):
# NOTE: not the most efficient; it will do for a first version.
with immediate_atomic():
for obj in queryset:
obj.delete_deferred()
def delete_model(self, request, obj):
with immediate_atomic():
obj.delete_deferred()
@csrf_protect_m
def delete_view(self, request, object_id, extra_context=None):
# the superclass version, but with the transaction.atomic context manager commented out (we do this ourselves)
# with transaction.atomic(using=router.db_for_write(self.model)):
return self._delete_view(request, object_id, extra_context)

View File

@@ -1,3 +1,4 @@
import hashlib
from django.utils import timezone
from projects.models import Project
@@ -11,6 +12,7 @@ def get_or_create_issue(project=None, event_data=None):
if event_data is None:
from events.factories import create_event_data
event_data = create_event_data()
if project is None:
project = Project.objects.create(name="Test project")
@@ -26,6 +28,7 @@ def get_or_create_issue(project=None, event_data=None):
grouping = Grouping.objects.create(
project=project,
grouping_key=grouping_key,
grouping_key_hash=hashlib.sha256(grouping_key.encode()).hexdigest(),
issue=issue,
)

View File

@@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("issues", "0013_fix_issue_stored_event_counts"),
]
operations = [
migrations.AddField(
model_name="grouping",
name="grouping_key_hash",
field=models.CharField(default="", max_length=64),
preserve_default=False,
),
]

View File

@@ -0,0 +1,20 @@
import hashlib
from django.db import migrations
def set_grouping_hash(apps, schema_editor):
Grouping = apps.get_model("issues", "Grouping")
for grouping in Grouping.objects.all():
grouping.grouping_key_hash = hashlib.sha256(grouping.grouping_key.encode()).hexdigest()
grouping.save()
class Migration(migrations.Migration):
dependencies = [
("issues", "0014_grouping_grouping_key_hash"),
]
operations = [
migrations.RunPython(set_grouping_hash),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("projects", "0011_fill_stored_event_count"),
("issues", "0015_set_grouping_hash"),
]
operations = [
migrations.AlterUniqueTogether(
name="grouping",
unique_together={("project", "grouping_key_hash")},
),
]

View File

@@ -0,0 +1,55 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("issues", "0016_alter_grouping_unique_together"),
]
operations = [
migrations.RemoveIndex(
model_name="issue",
name="issues_issu_first_s_9fb0f9_idx",
),
migrations.RemoveIndex(
model_name="issue",
name="issues_issu_last_se_400a05_idx",
),
migrations.RemoveIndex(
model_name="issue",
name="issues_issu_is_reso_eaf32b_idx",
),
migrations.RemoveIndex(
model_name="issue",
name="issues_issu_is_mute_6fe7fc_idx",
),
migrations.RemoveIndex(
model_name="issue",
name="issues_issu_is_reso_0b6923_idx",
),
migrations.AddIndex(
model_name="issue",
index=models.Index(
fields=["project", "is_resolved", "is_muted", "last_seen"],
name="issue_list_open",
),
),
migrations.AddIndex(
model_name="issue",
index=models.Index(
fields=["project", "is_muted", "last_seen"], name="issue_list_muted"
),
),
migrations.AddIndex(
model_name="issue",
index=models.Index(
fields=["project", "is_resolved", "last_seen"],
name="issue_list_resolved",
),
),
migrations.AddIndex(
model_name="issue",
index=models.Index(fields=["project", "last_seen"], name="issue_list_all"),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("issues", "0017_issue_list_indexes_must_start_with_project"),
]
operations = [
migrations.AddField(
model_name="issue",
name="is_deleted",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("issues", "0018_issue_is_deleted"),
]
operations = [
migrations.AlterField(
model_name="grouping",
name="grouping_key_hash",
field=models.CharField(max_length=64, null=True),
),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations
def remove_objects_with_null_issue(apps, schema_editor):
# Up until now, we have various models w/ .issue=FK(null=True, on_delete=models.SET_NULL)
# Although it is "not expected" in the interface, issue-deletion would have led to those
# objects with a null issue. We're about to change that to .issue=FK(null=False, ...) which
# would crash if we don't remove those objects first. Object-removal is "fine" though, because
# as per the meaning of the SET_NULL, these objects were "dangling" anyway.
Grouping = apps.get_model("issues", "Grouping")
TurningPoint = apps.get_model("issues", "TurningPoint")
Grouping.objects.filter(issue__isnull=True).delete()
TurningPoint.objects.filter(issue__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
("issues", "0019_alter_grouping_grouping_key_hash"),
]
operations = [
migrations.RunPython(remove_objects_with_null_issue, reverse_code=migrations.RunPython.noop),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("issues", "0020_remove_objects_with_null_issue"),
]
operations = [
migrations.AlterField(
model_name="grouping",
name="issue",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING, to="issues.issue"
),
),
migrations.AlterField(
model_name="turningpoint",
name="issue",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING, to="issues.issue"
),
),
]

View File

@@ -0,0 +1,22 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("projects", "0012_project_is_deleted"),
("issues", "0021_alter_do_nothing"),
]
operations = [
migrations.AddField(
model_name="turningpoint",
name="project",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="projects.project",
),
),
]

View File

@@ -0,0 +1,36 @@
from django.db import migrations
def turningpoint_set_project(apps, schema_editor):
TurningPoint = apps.get_model("issues", "TurningPoint")
# TurningPoint.objects.update(project=F("issue__project"))
# fails with 'Joined field references are not permitted in this query"
# This one's elegant and works in sqlite but not in MySQL:
# TurningPoint.objects.update(
# project=Subquery(
# TurningPoint.objects
# .filter(pk=OuterRef('pk'))
# .values('issue__project')[:1]
# )
# )
# django.db.utils.OperationalError: (1093, "You can't specify target table 'issues_turningpoint' for update in FROM
# clause")
# so in the end we'll just loop:
for turningpoint in TurningPoint.objects.all():
turningpoint.project = turningpoint.issue.project
turningpoint.save(update_fields=["project"])
class Migration(migrations.Migration):
dependencies = [
("issues", "0022_turningpoint_project"),
]
operations = [
migrations.RunPython(turningpoint_set_project, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,36 @@
from django.db import migrations, models
import django.db.models.deletion
def delete_turningpoints_pointing_to_null_project(apps, schema_editor):
# In 0023_turningpoint_set_project, we set the project field for TurningPoint to the associated Issue's project.
# _However_, at that point in time in our migration-history, Issue's project field was still nullable, and the big
# null-project-fk-deleting migration (projects/migrations/0013_delete_objects_pointing_to_null_project.py) is _sure_
# not to have run yet (it depends on the present migration). (it wouldn't delete TurningPoints anyway, but it would
# delete project-less Issues). Anyway, we just take care of the TurningPoints here (that's ok as per 0013_delete_...
# logic, i.e. no-project means no way to access) and it's also possible since they are on the edge of our object
# graph.
TurningPoint = apps.get_model("issues", "TurningPoint")
TurningPoint.objects.filter(project__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
("projects", "0012_project_is_deleted"),
("issues", "0023_turningpoint_set_project"),
]
operations = [
migrations.RunPython(
delete_turningpoints_pointing_to_null_project,
migrations.RunPython.noop,
),
migrations.AlterField(
model_name="turningpoint",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project"
),
),
]

View File

@@ -0,0 +1,28 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
# Django came up with 0014, whatever the reason, I'm sure that 0013 is at least required (as per comments there)
("projects", "0014_alter_projectmembership_project"),
("issues", "0024_turningpoint_project_alter_not_null"),
]
operations = [
migrations.AlterField(
model_name="grouping",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project"
),
),
migrations.AlterField(
model_name="issue",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project"
),
),
]

View File

@@ -10,6 +10,7 @@ from django.conf import settings
from django.utils.functional import cached_property
from bugsink.volume_based_condition import VolumeBasedCondition
from bugsink.transaction import delay_on_commit
from alerts.tasks import send_unmute_alert
from compat.timestamp import parse_timestamp, format_timestamp
from tags.models import IssueTag, TagValue
@@ -18,6 +19,8 @@ from .utils import (
parse_lines, serialize_lines, filter_qs_for_fixed_at, exclude_qs_for_fixed_at,
get_title_for_exception_type_and_value)
from .tasks import delete_issue_deps
class IncongruentStateException(Exception):
pass
@@ -32,7 +35,9 @@ class Issue(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
project = models.ForeignKey(
"projects.Project", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later'
"projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING)
is_deleted = models.BooleanField(default=False)
# 1-based for the same reasons as Event.digest_order
digest_order = models.PositiveIntegerField(blank=False, null=False)
@@ -72,6 +77,21 @@ class Issue(models.Model):
self.digest_order = max_current + 1 if max_current is not None else 1
super().save(*args, **kwargs)
def delete_deferred(self):
"""Marks the issue as deleted, and schedules deletion of all related objects"""
self.is_deleted = True
self.save(update_fields=["is_deleted"])
# we set grouping_key_hash to None to ensure that event digests that happen simultaneously with the delayed
# cleanup will get their own fresh Grouping and hence Issue. This matches with the behavior that would happen
# if Issue deletion would have been instantaneous (i.e. it's the least surprising behavior).
#
# `issue=None` is explicitly _not_ part of this update, such that the actual deletion of the Groupings will be
# picked up as part of the delete_issue_deps task.
self.grouping_set.all().update(grouping_key_hash=None)
delay_on_commit(delete_issue_deps, str(self.project_id), str(self.id))
def friendly_id(self):
return f"{ self.project.slug.upper() }-{ self.digest_order }"
@@ -176,13 +196,11 @@ class Issue(models.Model):
("project", "digest_order"),
]
indexes = [
models.Index(fields=["first_seen"]),
models.Index(fields=["last_seen"]),
# 3 indexes for the list view (state_filter)
models.Index(fields=["is_resolved", "is_muted", "last_seen"]), # filter on resolved/muted
models.Index(fields=["is_muted", "last_seen"]), # filter on muted
models.Index(fields=["is_resolved", "last_seen"]), # filter on resolved
# 4 indexes for the list view (state_filter)
models.Index(fields=["project", "is_resolved", "is_muted", "last_seen"], name="issue_list_open"),
models.Index(fields=["project", "is_muted", "last_seen"], name="issue_list_muted"),
models.Index(fields=["project", "is_resolved", "last_seen"], name="issue_list_resolved"), # and unresolved
models.Index(fields=["project", "last_seen"], name="issue_list_all"), # all
]
@@ -195,18 +213,25 @@ class Grouping(models.Model):
into a single issue. (such manual merging is not yet implemented, but the data-model is already prepared for it)
"""
project = models.ForeignKey(
"projects.Project", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later'
"projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING)
# NOTE: I don't want to have any principled maximum on the grouping key, nor do I want to prematurely optimize the
# lookup. If lookups are slow, we _could_ examine whether manually hashing these values and matching on the hash
# helps.
grouping_key = models.TextField(blank=False, null=False)
issue = models.ForeignKey("Issue", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later'
# we hash the key to make it indexable on MySQL, see https://code.djangoproject.com/ticket/2495
grouping_key_hash = models.CharField(max_length=64, blank=False, null=True)
issue = models.ForeignKey("Issue", blank=False, null=False, on_delete=models.DO_NOTHING)
def __str__(self):
return self.grouping_key
class Meta:
unique_together = [
# principled: grouping _key_ is a _key_ for a reason (within a project). This also implies the main way of
# looking up groupings has an appropriate index.
("project", "grouping_key_hash"),
]
def format_unmute_reason(unmute_metadata):
if "mute_until" in unmute_metadata:
@@ -323,10 +348,15 @@ class IssueStateManager(object):
# path is never reached via UI-based paths (because those are by definition not event-triggered); thus
# the 2 ways of creating TurningPoints do not collide.
TurningPoint.objects.create(
project_id=issue.project_id,
issue=issue, triggering_event=triggering_event, timestamp=triggering_event.ingested_at,
kind=TurningPointKind.UNMUTED, metadata=json.dumps(unmute_metadata))
triggering_event.never_evict = True # .save() will be called by the caller of this function
@staticmethod
def delete(issue):
issue.delete_deferred()
@staticmethod
def get_unmute_thresholds(issue):
unmute_vbcs = [
@@ -445,6 +475,11 @@ class IssueQuerysetStateManager(object):
for issue in issue_qs:
IssueStateManager.unmute(issue, triggering_event)
@staticmethod
def delete(issue_qs):
for issue in issue_qs:
issue.delete_deferred()
class TurningPointKind(models.IntegerChoices):
# The language of the kinds reflects a historic view of the system, e.g. "first seen" as opposed to "new issue"; an
@@ -466,7 +501,8 @@ class TurningPoint(models.Model):
# basically: an Event, but that name was already taken in our system :-) alternative names I considered:
# "milestone", "state_change", "transition", "annotation", "episode"
issue = models.ForeignKey("Issue", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later'
project = models.ForeignKey("projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING)
issue = models.ForeignKey("Issue", blank=False, null=False, on_delete=models.DO_NOTHING)
triggering_event = models.ForeignKey("events.Event", blank=True, null=True, on_delete=models.DO_NOTHING)
# null: the system-user

82
issues/tasks.py Normal file
View File

@@ -0,0 +1,82 @@
from snappea.decorators import shared_task
from bugsink.utils import get_model_topography, delete_deps_with_budget
from bugsink.transaction import immediate_atomic, delay_on_commit
def get_model_topography_with_issue_override():
"""
Returns the model topography with ordering adjusted to prefer deletions via .issue, when available.
This assumes that Issue is not only the root of the dependency graph, but also that if a model has an .issue
ForeignKey, deleting it via that path is sufficient, meaning we can safely avoid visiting the same model again
through other ForeignKey routes (e.g. Event.grouping or TurningPoint.triggering_event).
The preference is encoded via an explicit list of models, which are visited early and only via their .issue path.
"""
from issues.models import TurningPoint, Grouping
from events.models import Event
from tags.models import IssueTag, EventTag
preferred = [
TurningPoint, # above Event, to avoid deletions via .triggering_event
EventTag, # above Event, to avoid deletions via .event
Event, # above Grouping, to avoid deletions via .grouping
Grouping,
IssueTag,
]
def as_preferred(lst):
"""
Sorts the list of (model, fk_name) tuples such that the models are in the preferred order as indicated above,
and models which occur with another fk_name are pruned
"""
return sorted(
[(model, fk_name) for model, fk_name in lst if fk_name == "issue" or model not in preferred],
key=lambda x: preferred.index(x[0]) if x[0] in preferred else len(preferred),
)
topo = get_model_topography()
for k, lst in topo.items():
topo[k] = as_preferred(lst)
return topo
@shared_task
def delete_issue_deps(project_id, issue_id):
from .models import Issue # avoid circular import
with immediate_atomic():
# matches what we do in events/retention.py (and for which argumentation exists); in practive I have seen _much_
# faster deletion times (in the order of .03s per task on my local laptop) when using a budget of 500, _but_
# it's not a given those were for "expensive objects" (e.g. events); and I'd rather err on the side of caution
# (worst case we have a bit of inefficiency; in any case this avoids hogging the global write lock / timeouts).
budget = 500
num_deleted = 0
dep_graph = get_model_topography_with_issue_override()
for model_for_recursion, fk_name_for_recursion in dep_graph["issues.Issue"]:
this_num_deleted = delete_deps_with_budget(
project_id,
model_for_recursion,
fk_name_for_recursion,
[issue_id],
budget - num_deleted,
dep_graph,
is_for_project=False,
)
num_deleted += this_num_deleted
if num_deleted >= budget:
delay_on_commit(delete_issue_deps, project_id, issue_id)
return
if budget - num_deleted <= 0:
# no more budget for the self-delete.
delay_on_commit(delete_issue_deps, project_id, issue_id)
else:
# final step: delete the issue itself
Issue.objects.filter(pk=issue_id).delete()

View File

@@ -1,45 +1,45 @@
{% load add_to_qs %}
<form action="{% url this_view issue_pk=issue.pk nav="last" %}" method="get">{# nav="last": when doing a new search on an event-page, you want the most recent matching event to show up #}
<input type="text" name="q" value="{{ q }}" placeholder="search..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md mr-2"/>
<input type="text" name="q" value="{{ q }}" placeholder="search..." class="focus:border-cyan-500 dark:focus:border-cyan-400 focus:ring-cyan-200 dark:focus:ring-cyan-700 rounded-md mr-2"/>
</form>
{% if has_prev %} {# no need for 'is_first': if you can go to the left, you can go all the way to the left too #}
<a href="{% url this_view issue_pk=issue.pk nav="first" %}{% current_qs %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="First event">
<a href="{% url this_view issue_pk=issue.pk nav="first" %}{% current_qs %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="First event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M3.22 7.595a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 0 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06l-3.25 3.25Zm8.25-3.25-3.25 3.25a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 1 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06Z" clip-rule="evenodd" /></svg>
</a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="First event">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="First event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M3.22 7.595a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 0 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06l-3.25 3.25Zm8.25-3.25-3.25 3.25a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 1 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06Z" clip-rule="evenodd" /></svg>
</div>
{% endif %}
{% if has_prev %}
<a href="{% url this_view issue_pk=issue.pk digest_order=event.digest_order nav="prev" %}{% current_qs %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="Previous event">
<a href="{% url this_view issue_pk=issue.pk digest_order=event.digest_order nav="prev" %}{% current_qs %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="Previous event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" /></svg>
</a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Previous event">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Previous event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" /></svg>
</div>
{% endif %}
{% if has_next %}
<a href="{% url this_view issue_pk=issue.pk digest_order=event.digest_order nav="next" %}{% current_qs %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="Next event">
<a href="{% url this_view issue_pk=issue.pk digest_order=event.digest_order nav="next" %}{% current_qs %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="Next event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
</a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Next event">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Next event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
</div>
{% endif %}
{% if has_next %}
<a href="{% url this_view issue_pk=issue.pk nav="last" %}{% current_qs %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="Last event">
<a href="{% url this_view issue_pk=issue.pk nav="last" %}{% current_qs %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="Last event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M12.78 7.595a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06l3.25 3.25Zm-8.25-3.25 3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06Z" clip-rule="evenodd" /></svg>
</a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Last event">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Last event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M12.78 7.595a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06l3.25 3.25Zm-8.25-3.25 3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06Z" clip-rule="evenodd" /></svg>
</div>

View File

@@ -14,16 +14,16 @@
{% csrf_token %}
{% if issue.is_resolved %}{# i.e. buttons disabled #}
{# see issues/tests.py for why this is turned off ATM #}
{# <button class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md" name="action" value="reopen">Reopen</button> #}
{# <button class="font-bold text-slate-800 dark:text-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 active:ring rounded-md" name="action" value="reopen">Reopen</button> #}
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if issue.project.has_releases %}
<button disabled class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-s-md" name="action" value="resolved_next">Resolved in next release</button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-s-md" name="action" value="resolved_next">Resolved in next release</button>
<button disabled class="font-bold text-slate-300 fill-slate-300 stroke-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-t-2 border-b-2 rounded-e-md"><svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 fill-slate-300 dark:fill-slate-600 stroke-slate-300 dark:stroke-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-t-2 border-b-2 rounded-e-md"><svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
{# we just hide the whole dropdown; this is the easiest implementation of not-showing the dropdown #}
{% else %}
<button disabled class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-md" name="action" value="resolved">Resolve</button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-md" name="action" value="resolved">Resolve</button>
{% endif %}
{% endspaceless %}
@@ -31,27 +31,27 @@
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if issue.project.has_releases %}
{# 'by next' is shown even if 'by current' is also shown: just because you haven't seen 'by current' doesn't mean it's actually already solved; and in fact we show this option first precisely because we can always show it #}
<button class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-s-md" name="action" value="resolved_next">Resolved in next release</button>
{# 'by next' is shown even if 'by current' is also shown: just because you haven't seen 'by current' doesn't mean it's actually already solved; and in fact we show this option first precisely because we can always show it #}
<button class="font-bold text-slate-800 dark:text-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 active:ring rounded-s-md" name="action" value="resolved_next">Resolved in next release</button>
<div class="dropdown">
<button disabled {# disabled b/c clicking the dropdown does nothing - we have hover for that #} class="font-bold text-slate-800 fill-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-r-2 border-t-2 border-b-2 hover:bg-slate-200 active:ring rounded-e-md"><svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<button disabled {# disabled b/c clicking the dropdown does nothing - we have hover for that #} class="font-bold text-slate-800 dark:text-slate-100 fill-slate-800 dark:fill-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 border-r-2 border-t-2 border-b-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-e-md"><svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
{# note that we can depend on get_latest_release being available, because we're in the 'project.has_releases' branch #}
<div class="dropdown-content-right flex-col pl-2">
{% if not issue.occurs_in_last_release %}
<button name="action" value="resolved_release:{{ issue.project.get_latest_release.version }}" class="block self-stretch font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white hover:bg-slate-200 active:ring text-left whitespace-nowrap">Resolved in latest ({{ issue.project.get_latest_release.get_short_version }})</button>
<button name="action" value="resolved_release:{{ issue.project.get_latest_release.version }}" class="block self-stretch font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white dark:bg-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring text-left whitespace-nowrap">Resolved in latest ({{ issue.project.get_latest_release.get_short_version }})</button>
{% else %}
<button name="action" value="resolved_release:{{ issue.project.get_latest_release.version }}" disabled class="block self-stretch font-bold text-slate-300 border-slate-200 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white text-left whitespace-nowrap">Resolved in latest ({{ issue.project.get_latest_release.get_short_version }})</button>
<button name="action" value="resolved_release:{{ issue.project.get_latest_release.version }}" disabled class="block self-stretch font-bold text-slate-300 dark:text-slate-600 border-slate-200 dark:border-slate-700 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white dark:bg-slate-900 text-left whitespace-nowrap">Resolved in latest ({{ issue.project.get_latest_release.get_short_version }})</button>
{% endif %}
</div>
</div>
</div>
{% else %}
<button class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md" name="action" value="resolve">Resolve</button>
<button class="font-bold text-slate-800 dark:text-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 active:ring rounded-md" name="action" value="resolve">Resolve</button>
{% endif %}
{% endspaceless %}
@@ -59,33 +59,33 @@
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if not issue.is_muted and not issue.is_resolved %}
<button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-s-md" name="action" value="mute">Mute</button>
<button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-s-md" name="action" value="mute">Mute</button>
{% else %}
<button disabled class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-s-md" name="action" value="mute">Mute</button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-s-md" name="action" value="mute">Mute</button>
{% endif %}
<div class="dropdown">
{% if not issue.is_muted and not issue.is_resolved %}
<button disabled {# disabled b/c clicking the dropdown does nothing - we have hover for that #} class="font-bold text-slate-500 fill-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 hover:bg-slate-200 active:ring">Mute for/until&nbsp;&nbsp;<svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<button disabled {# disabled b/c clicking the dropdown does nothing - we have hover for that #} class="font-bold text-slate-500 dark:text-slate-300 fill-slate-500 dark:fill-slate-500 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring">Mute for/until&nbsp;&nbsp;<svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<div class="dropdown-content-right flex-col">
{% for mute_option in mute_options %}
<button name="action" value="mute_{{ mute_option.for_or_until }}:{{ mute_option.period_name }},{{ mute_option.nr_of_periods }},{{ mute_option.gte_threshold }}" class="block self-stretch font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white hover:bg-slate-200 active:ring text-left whitespace-nowrap">{% if mute_option.for_or_until == "for" %}{{ mute_option.nr_of_periods }} {{ mute_option.period_name }}{% if mute_option.nr_of_periods != 1 %}s{% endif %}{% else %}{{ mute_option.gte_threshold }} events per {% if mute_option.nr_of_periods != 1%} {{ mute_option.nr_of_periods }} {{ mute_option.period_name }}s{% else %} {{ mute_option.period_name }}{% endif %}{% endif %}</button>
<button name="action" value="mute_{{ mute_option.for_or_until }}:{{ mute_option.period_name }},{{ mute_option.nr_of_periods }},{{ mute_option.gte_threshold }}" class="block self-stretch font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white dark:bg-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring text-left whitespace-nowrap">{% if mute_option.for_or_until == "for" %}{{ mute_option.nr_of_periods }} {{ mute_option.period_name }}{% if mute_option.nr_of_periods != 1 %}s{% endif %}{% else %}{{ mute_option.gte_threshold }} events per {% if mute_option.nr_of_periods != 1%} {{ mute_option.nr_of_periods }} {{ mute_option.period_name }}s{% else %} {{ mute_option.period_name }}{% endif %}{% endif %}</button>
{% endfor %}
</div>
{% else %}
<button disabled class="font-bold text-slate-300 fill-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2">Mute for/until&nbsp;&nbsp;<svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 fill-slate-300 dark:fill-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2">Mute for/until&nbsp;&nbsp;<svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
{# note that when the issue is muted, no further muting is allowed. this is a design decision, I figured this is the easiest-to-understand UI, #}
{# both at the point-of-clicking and when displaying the when-will-this-be-unmuted in some place #}
{# (the alternative would be to allow multiple simulteneous reasons for unmuting to exist next to each other #}
{# we just hide the whole dropdown; this is the easiest implementation of not-showing the dropdown when the issue is already muted #}
{% endif %}
</div>
</div>
{% if issue.is_muted and not issue.is_resolved %}
<button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 hover:bg-slate-200 active:ring rounded-e-md" name="action" value="unmute">Unmute</button>
<button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-e-md" name="action" value="unmute">Unmute</button>
{% else %}
<button disabled class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 rounded-e-md" name="action" value="unmute">Unmute</button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 rounded-e-md" name="action" value="unmute">Unmute</button>
{% endif %}
{% endspaceless %}
@@ -106,14 +106,14 @@
{# overflow-x-auto is needed at the level of the flex item such that it works at the level where we need it (the code listings)#}
<div class="ml-4 mb-4 mr-4 border-2 overflow-x-auto flex-[2_1_96rem]"><!-- the whole of the big tabbed view--> {# 96rem is 1536px, which matches the 2xl class; this is no "must" but eyeballing revealed: good result #}
<div class="flex bg-slate-50 border-b-2"><!-- container for the actual tab buttons -->
<a href="/issues/issue/{{ issue.id }}/event/{% if event %}{{ event.id }}{% else %}last{% endif %}/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 {% if tab == "stacktrace" %}text-cyan-500 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Stacktrace</div></a>
<a href="/issues/issue/{{ issue.id }}/event/{% if event %}{{ event.id }}{% else %}last{% endif %}/details/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 {% if tab == "event-details" %}text-cyan-500 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Event&nbsp;Details</div></a>
<a href="/issues/issue/{{ issue.id }}/event/{% if event %}{{ event.id }}{% else %}last{% endif %}/breadcrumbs/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 {% if tab == "breadcrumbs" %}text-cyan-500 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Breadcrumbs</div></a>
<a href="/issues/issue/{{ issue.id }}/events/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 {% if tab == "event-list" %}text-cyan-500 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Event&nbsp;List</div></a>
<a href="/issues/issue/{{ issue.id }}/tags/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 {% if tab == "tags" %}text-cyan-500 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Tags</div></a>
<a href="/issues/issue/{{ issue.id }}/grouping/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 {% if tab == "grouping" %}text-cyan-500 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Grouping</div></a>
<a href="/issues/issue/{{ issue.id }}/history/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 {% if tab == "history" %}text-cyan-500 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">History</div></a>
<div class="flex bg-slate-50 dark:bg-slate-800 border-b-2"><!-- container for the actual tab buttons -->
<a href="/issues/issue/{{ issue.id }}/event/{% if event %}{{ event.id }}{% else %}last{% endif %}/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if tab == "stacktrace" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Stacktrace</div></a>
<a href="/issues/issue/{{ issue.id }}/event/{% if event %}{{ event.id }}{% else %}last{% endif %}/details/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if tab == "event-details" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Event&nbsp;Details</div></a>
<a href="/issues/issue/{{ issue.id }}/event/{% if event %}{{ event.id }}{% else %}last{% endif %}/breadcrumbs/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if tab == "breadcrumbs" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Breadcrumbs</div></a>
<a href="/issues/issue/{{ issue.id }}/events/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if tab == "event-list" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Event&nbsp;List</div></a>
<a href="/issues/issue/{{ issue.id }}/tags/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if tab == "tags" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Tags</div></a>
<a href="/issues/issue/{{ issue.id }}/grouping/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if tab == "grouping" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">Grouping</div></a>
<a href="/issues/issue/{{ issue.id }}/history/{% current_qs %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if tab == "history" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4{% else %}text-slate-500 border-slate-400 hover:border-b-4{% endif %}">History</div></a>
</div>
<div class="m-4"><!-- div for tab_content -->
@@ -121,9 +121,9 @@
{% endblock %}
</div>
<div class="flex p-4 bg-slate-200 border-b-2"><!-- bottom nav bar -->
<div class="flex p-4 bg-slate-200 dark:bg-slate-800 border-b-2"><!-- bottom nav bar -->
{% if is_event_page %}<div>Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} which occured at <span class="font-bold">{{ event.ingested_at|date:"j M G:i T" }}</span></div>{% endif %}
<div class="ml-auto pr-4 font-bold text-slate-500">
<div class="ml-auto pr-4 font-bold text-slate-500 dark:text-slate-300">
{% if is_event_page %}
<a href="/events/event/{{ event.id }}/download/">Download</a>
| <a href="/events/event/{{ event.id }}/raw/" >JSON</a>
@@ -131,7 +131,7 @@
{% endif %}
{% if app_settings.USE_ADMIN and user.is_staff %}
{% if is_event_page %}
{% if is_event_page %}
| <a href="/admin/events/event/{{ event.id }}/change/">Event Admin</a> |
{% endif %}
<a href="/admin/issues/issue/{{ issue.id }}/change/">Issue Admin</a>
@@ -145,19 +145,19 @@
<div class="border-2 mb-4 mr-4"><!-- "issue: key info" box -->
<div class="font-bold border-b-2">
<div class="p-4 border-slate-50 text-slate-500 border-b-2"> {# div-in-div to match the spacing of the tabs, which is caused by the hover-thick-line; we use border-2 on both sides rather than border-b-4 to get the text aligned centeredly #}
<div class="p-4 border-slate-50 dark:border-slate-900 text-slate-500 dark:text-slate-300"> {# div-in-div to match the spacing of the tabs, which is caused by the hover-thick-line; we use border-2 on both sides rather than border-b-4 to get the text aligned centeredly #}
Issue Key Info
</div>
</div>
<div class="p-4">
<div class="mb-4">
<div class="text-sm font-bold text-slate-500">Issue #</div>
<div class="text-sm font-bold text-slate-500 dark:text-slate-300">Issue #</div>
<div>{{ issue.friendly_id }} </div>
</div>
<div class="mb-4">
<div class="text-sm font-bold text-slate-500">State</div>
<div class="text-sm font-bold text-slate-500 dark:text-slate-300">State</div>
<div>
{% if issue.is_resolved %}
Resolved
@@ -185,7 +185,7 @@
</div>
<div class="mb-4">
<div class="text-sm font-bold text-slate-500">Nr. of events:</div>
<div class="text-sm font-bold text-slate-500 dark:text-slate-300">Nr. of events:</div>
<div>{{ issue.digested_event_count|intcomma }}
{% if issue.digested_event_count != issue.stored_event_count %}
total seen</div><div>{{ issue.stored_event_count|intcomma }} available</div>
@@ -196,24 +196,24 @@
{% if issue.digested_event_count > 1 %}
<div class="mb-4">
<div class="text-sm font-bold text-slate-500">First seen:</div>
<div class="text-sm font-bold text-slate-500 dark:text-slate-300">First seen:</div>
<div>{{ issue.first_seen|date:"j M G:i T" }}</div>
</div>
<div class="mb-4">
<div class="text-sm font-bold text-slate-500">Last seen:</div>
<div class="text-sm font-bold text-slate-500 dark:text-slate-300">Last seen:</div>
<div>{{ issue.last_seen|date:"j M G:i T" }}</div>
</div>
{% else %}
<div class="mb-4">
<div class="text-sm font-bold text-slate-500">Seen at:</div>
<div class="text-sm font-bold text-slate-500 dark:text-slate-300">Seen at:</div>
<div>{{ issue.first_seen|date:"j M G:i T" }}</div>
</div>
{% endif %}
{% if issue.get_events_at_2 %}
<div class="mb-4">
<div class="text-sm font-bold text-slate-500">Seen in releases:</div>
<div class="text-sm font-bold text-slate-500 dark:text-slate-300">Seen in releases:</div>
<div>
{% for version in issue.get_events_at_2 %}
<span {% if version|issha %}class="font-mono"{% endif %}>{{ version|shortsha }}{% if not forloop.last %}</span>,{% endif %}
@@ -228,16 +228,16 @@
{% if tab != "tags" and issue.tags_summary %}
<div class="border-2 mb-4 mr-4"><!-- "issue: tags" box -->
<div class="font-bold border-b-2">
<div class="p-4 border-slate-50 text-slate-500">
<div class="p-4 border-slate-50 dark:border-slate-900 text-slate-500 dark:text-slate-300">
Issue Tags
</div>
</div>
<div class="p-4">
{% for issuetags in issue.tags_summary %}
<div class="mb-4">
<div class="text-sm font-bold text-slate-500">{{ issuetags.0.key.key }}:</div>
<div class="text-sm font-bold text-slate-500 dark:text-slate-300">{{ issuetags.0.key.key }}:</div>
<div>
{% for issuetag in issuetags %}
{% for issuetag in issuetags %}
<span>{{ issuetag.value.value }} <span class="text-xs">({{ issuetag.pct }}%)</span>{% if not forloop.last %}</span>,{% endif %}
{% endfor %}
</div>

View File

@@ -8,7 +8,7 @@
<div class="flex">
<div class="overflow-hidden">
<div class="italic">{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} total{% if q %} — {{ event_qs_count|intcomma }} found by search{% endif %})</div>
<div class="italic text-ellipsis whitespace-nowrap overflow-hidden">{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} total{% if q %} — {{ event_qs_count|intcomma }} found by search{% endif %})</div>
</div>
<div class="ml-auto flex-none">
@@ -32,14 +32,14 @@
<tbody>
{% for breadcrumb in breadcrumbs %}
<tr class="border-slate-200 border-2 ">
<tr class="border-slate-200 dark:border-slate-700 border-2 ">
<td class="p-4 font-bold text-slate-500 align-top">
<td class="p-4 font-bold text-slate-500 dark:text-slate-300 align-top">
{{ breadcrumb.category }}
</td>
{% comment %}
{# not _that_ useful
{# not _that_ useful
<td class="ml-0 pb-4 pt-4 pr-4">
{{ breadcrumb.type }}
</td>
@@ -48,7 +48,7 @@
<td class="w-full p-4 font-mono">
{{ breadcrumb.message }}
</td>
<td class="p-4 font-bold text-slate-500 align-top">
<td class="p-4 font-bold text-slate-500 dark:text-slate-300 align-top">
{{ breadcrumb.timestamp }}
{# {{ breadcrumb.timestamp|date:"G:i T" and milis }} #}
</td>

View File

@@ -16,34 +16,34 @@
<div class="flex place-content-end">
{# copy/paste of _event_nav, but not based on any event (we have none), prev/next are meaningless also; first/last only when we have an event_qs to navigate through #}
<form action="{% url this_view issue_pk=issue.pk nav="last" %}" method="get">{# nav="last": when doing a new search on an event-page, you want the most recent matching event to show up #}
<input type="text" name="q" value="{{ q }}" placeholder="search..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md mr-2"/>
<input type="text" name="q" value="{{ q }}" placeholder="search..." class="focus:border-cyan-500 dark:focus:border-cyan-400 focus:ring-cyan-200 dark:focus:ring-cyan-700 rounded-md mr-2"/>
</form>
{% if event_qs_count %}
<a href="{% url this_view issue_pk=issue.pk nav="first" %}{% current_qs %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="First event">
<a href="{% url this_view issue_pk=issue.pk nav="first" %}{% current_qs %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="First event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M3.22 7.595a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 0 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06l-3.25 3.25Zm8.25-3.25-3.25 3.25a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 1 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06Z" clip-rule="evenodd" />
</svg>
</a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="First event">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="First event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M3.22 7.595a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 0 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06l-3.25 3.25Zm8.25-3.25-3.25 3.25a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 1 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06Z" clip-rule="evenodd" /></svg>
</div>
{% endif %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Previous event">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Previous event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" /></svg>
</div>
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Next event">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Next event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
</div>
{% if event_qs_count %}
<a href="{% url this_view issue_pk=issue.pk nav="last" %}{% current_qs %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="Last event">
<a href="{% url this_view issue_pk=issue.pk nav="last" %}{% current_qs %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="Last event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M12.78 7.595a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06l3.25 3.25Zm-8.25-3.25 3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06Z" clip-rule="evenodd" /></svg>
</a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Last event">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Last event">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M12.78 7.595a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06l3.25 3.25Zm-8.25-3.25 3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06Z" clip-rule="evenodd" /></svg>
</div>

View File

@@ -7,7 +7,7 @@
<div class="flex">
<div class="overflow-hidden">
<div class="italic">{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} total{% if q %} — {{ event_qs_count|intcomma }} found by search{% endif %})</div>
<div class="italic text-ellipsis whitespace-nowrap overflow-hidden">{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} total{% if q %} — {{ event_qs_count|intcomma }} found by search{% endif %})</div>
</div>
<div class="ml-auto flex-none">
@@ -24,21 +24,21 @@
<div class="mb-6">
{% for key, value in key_info %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
</div>
{% if logentry_info %}
<h1 id="logentry" class="text-2xl font-bold mt-4">Log Entry</h1>
<div class="mb-6">
{% for key, value in logentry_info %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
</div>
@@ -47,12 +47,12 @@
{% if deployment_info %}
<h1 id="deployment" class="text-2xl font-bold mt-4">Deployment</h1>
<div class="mb-6">
{% for key, value in deployment_info %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
</div>
@@ -60,12 +60,12 @@
{% if event.get_tags %}
<h1 id="tags" class="text-2xl font-bold mt-4">Tags</h1>
<div class="mb-6">
{% for tag in event.get_tags %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ tag.value.key.key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ tag.value.value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ tag.value.key.key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ tag.value.value|linebreaks }}</div>
</div>
{% endfor %}
</div>
@@ -75,12 +75,12 @@
{# note: in the (September 2024) sentry.io interface, user info is displayed under 'contexts', but in the data it simply lives top-level as #}
{# is implied by parsed_data.user -- I checked in a recent (September 2024) event.schema.json #}
<h1 id="user" class="text-2xl font-bold mt-4">User</h1>
<div class="mb-6">
{% for key, value in parsed_data.user|items %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
</div>
@@ -89,13 +89,13 @@
{% if parsed_data.request %}
<h1 id="request" class="text-2xl font-bold mt-4">Request</h1>
<div class="mb-6">
<div>
{% for key, value in parsed_data.request|items %}
{% if key != "headers" and key != "env" %}{# we deal with these below #}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>{# forloop.last doesn't work given the if-statement; we can fix that by pre-processing in the view #}
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>{# forloop.last doesn't work given the if-statement; we can fix that by pre-processing in the view #}
</div>
{% endif %}
{% endfor %}
@@ -105,9 +105,9 @@
<h3 class="font-bold mt-2">REQUEST HEADERS</h3>
<div>
{% for key, value in parsed_data.request.headers.items %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %} border-dotted">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %} border-dotted">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
</div>
@@ -118,9 +118,9 @@
<div>
{% for key, value in parsed_data.request.env.items %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %} border-dotted">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %} border-dotted">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
@@ -133,14 +133,14 @@
{% if contexts %}
<h1 id="runtime" class="text-2xl font-bold mt-4">Contexts</h1>
<div class="mb-6">
{% for context_key, context in contexts|items %}
<h3 class="font-bold mt-2">{{ context_key|upper }}</h3>
{% for key, value in context|items %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
{% endfor %}
@@ -150,7 +150,7 @@
{% comment %}
earlier I said about "tracing": I don't believe much in this whole business of tracing, so I'm not going to display the associated data either
now that we "just display all contexts" this is no longer true... some of the feeling persists, but I don't think
now that we "just display all contexts" this is no longer true... some of the feeling persists, but I don't think
that I'm so much anti-tracing that I want specifically exclude it from a generic loop. The data's there, let's just
show it (in a non-special way)
{% endcomment %}
@@ -163,12 +163,12 @@ the fact that we commented-out rather than clobbered reveals a small amount of d
{% if parsed_data.contexts.runtime %}
{# sentry gives this prime location (even a picture)... but why... it's kinda obvious what you're working in right? Maybe I could put it at the top of the modules list instead. And check if there's any other relevant info in that runtime context (RTFM) #}
<h1 id="runtime" class="text-2xl font-bold mt-4">Runtime</h1>
<div class="mb-6">
{% for key, value in parsed_data.contexts.runtime|items %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
</div>
@@ -177,14 +177,14 @@ the fact that we commented-out rather than clobbered reveals a small amount of d
{% if parsed_data.modules %}
<h1 id="modules" class="text-2xl font-bold mt-4">Modules</h1>
<div class="mb-6">
{# we have observed that (emperically) the keys in most of the above are sorted in some kind of meaningful way from important to non-important #}
{# however, for modules I'd rather just have an alphabetical list. #}
{% for key, value in parsed_data.modules|sorted_items %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div>
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
</div>
@@ -192,12 +192,25 @@ the fact that we commented-out rather than clobbered reveals a small amount of d
{% if parsed_data.sdk %}
<h1 id="sdk" class="text-2xl font-bold mt-4">SDK</h1>
<div class="mb-6">
{% for key, value in parsed_data.sdk|items %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div> {# the actual value may be a dict/list, but we'll just print it as a string; this is plenty of space for something as (hopefully) irrelevant as the SDK #}
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div> {# the actual value may be a dict/list, but we'll just print it as a string; this is plenty of space for something as (hopefully) irrelevant as the SDK #}
</div>
{% endfor %}
</div>
{% endif %}
{% if sourcemaps_images %}
<h1 id="sourcemaps_images" class="text-2xl font-bold mt-4">Sourcemap IDs</h1>
<div class="mb-6">
{% for key, value in sourcemaps_images %}
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div>
</div>
{% endfor %}
</div>
@@ -205,12 +218,12 @@ the fact that we commented-out rather than clobbered reveals a small amount of d
{% if parsed_data.extra %}
<h1 id="extra" class="text-2xl font-bold mt-4">Extra</h1>
<div class="mb-6">
{% for key, value in parsed_data.extra|items %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ value|linebreaks }}</div> {# the actual value may be a dict/list, but we'll just print it as a string; this is plenty of space for something as (hopefully) irrelevant #}
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-1/4 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ key }}</div>
<div class="w-3/4 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ value|linebreaks }}</div> {# the actual value may be a dict/list, but we'll just print it as a string; this is plenty of space for something as (hopefully) irrelevant #}
</div>
{% endfor %}
</div>

View File

@@ -9,7 +9,7 @@
<div class="flex">
<div class="overflow-hidden">
<div class="italic">
Showing {{ page_obj.start_index|intcomma }} - {{ page_obj.end_index|intcomma }} of
Showing {{ page_obj.start_index|intcomma }} - {{ page_obj.end_index|intcomma }} of
{% if page_obj.paginator.count == issue.stored_event_count and issue.stored_event_count == issue.digested_event_count %} {# all equal #}
{{ page_obj.paginator.count|intcomma }} total events.
{% elif page_obj.paginator.count == issue.stored_event_count and issue.stored_event_count != issue.digested_event_count %} {# evictions applied #}
@@ -27,44 +27,44 @@
{# adapted copy/pasta from _event_nav #}
<div class="flex place-content-end">
<form action="." method="get">
<input type="text" name="q" value="{{ q }}" placeholder="search..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md mr-2"/>
<input type="text" name="q" value="{{ q }}" placeholder="search..." class="focus:border-cyan-500 dark:focus:border-cyan-400 focus:ring-cyan-200 dark:focus:ring-cyan-700 rounded-md mr-2"/>
</form>
{% if page_obj.has_previous %} {# no need for 'is_first': if you can go to the left, you can go all the way to the left too #}
<a href="?{% add_to_qs page=1 %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="First page">
<a href="?{% add_to_qs page=1 %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="First page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M3.22 7.595a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 0 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06l-3.25 3.25Zm8.25-3.25-3.25 3.25a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 1 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06Z" clip-rule="evenodd" /></svg></a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="First page">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="First page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M3.22 7.595a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 0 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06l-3.25 3.25Zm8.25-3.25-3.25 3.25a.75.75 0 0 0 0 1.06l3.25 3.25a.75.75 0 1 0 1.06-1.06l-2.72-2.72 2.72-2.72a.75.75 0 0 0-1.06-1.06Z" clip-rule="evenodd" /></svg>
</div>
{% endif %}
{% if page_obj.has_previous %}
<a href="?{% add_to_qs page=page_obj.previous_page_number %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="Previous page">
<a href="?{% add_to_qs page=page_obj.previous_page_number %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="Previous page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" /></svg>
</a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Previous page">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Previous page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" /></svg>
</div>
{% endif %}
{% if page_obj.has_next %}
<a href="?{% add_to_qs page=page_obj.next_page_number %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="Next page">
<a href="?{% add_to_qs page=page_obj.next_page_number %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="Next page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
</a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Next page">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Next page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
</div>
{% endif %}
{% if page_obj.has_next %}
<a href="?{% add_to_qs page=page_obj.paginator.num_pages %}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring inline-flex items-center justify-center" title="Last page">
<a href="?{% add_to_qs page=page_obj.paginator.num_pages %}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring inline-flex items-center justify-center" title="Last page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M12.78 7.595a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06l3.25 3.25Zm-8.25-3.25 3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06Z" clip-rule="evenodd" /></svg>
</a>
{% else %}
<div class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Last page">
<div class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md inline-flex items-center justify-center" title="Last page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M12.78 7.595a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06l3.25 3.25Zm-8.25-3.25 3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06Z" clip-rule="evenodd" /></svg>
</div>
@@ -79,27 +79,27 @@
<thead>
<tr>
<td class="p-4 align-top text-slate-800 font-bold">
<td class="p-4 align-top text-slate-800 dark:text-slate-100 font-bold">
#
</td>
<td class="p-4 align-top text-slate-800 font-bold">
<td class="p-4 align-top text-slate-800 dark:text-slate-100 font-bold">
ID
</td>
<td class="p-4 align-top text-slate-800 font-bold">
<td class="p-4 align-top text-slate-800 dark:text-slate-100 font-bold">
Timestamp
</td>
<td class="p-4 w-full align-top text-slate-800 font-bold">
<td class="p-4 w-full align-top text-slate-800 dark:text-slate-100 font-bold">
Title
</td>
<td class="p-4 align-top text-slate-800 font-bold">
<td class="p-4 align-top text-slate-800 dark:text-slate-100 font-bold">
Release
</td>
<td class="p-4 align-top text-slate-800 font-bold">
<td class="p-4 align-top text-slate-800 dark:text-slate-100 font-bold">
Environment
</td>
</tr>
@@ -116,13 +116,13 @@ TODO
{% endcomment %}
{% for event in page_obj %}
<tr class="border-slate-200 border-2 ">
<tr class="border-slate-200 dark:border-slate-700 border-2 ">
<td class="p-4 font-bold text-slate-500 align-top">
<td class="p-4 font-bold text-slate-500 dark:text-slate-300 align-top">
<a href="/issues/issue/{{ issue.id }}/event/{{ event.id }}/{% current_qs %}">{{ event.digest_order }}</a>
</td>
<td class="p-4 font-bold text-slate-500 align-top"> {# how useful is this really? #}
<td class="p-4 font-bold text-slate-500 dark:text-slate-300 align-top"> {# how useful is this really? #}
<a href="/issues/issue/{{ issue.id }}/event/{{ event.id }}/{% current_qs %}">{{ event.id|truncatechars:9 }}</a>
</td>
@@ -152,7 +152,7 @@ TODO
</tr>
{% empty %}
<tr>
<td colspan="6" class="p-4 text-slate-800 italic">
<td colspan="6" class="p-4 text-slate-800 dark:text-slate-100 italic">
No events found{% if q %} for "{{ q }}"{% endif %}.
</td>
</tr>

View File

@@ -11,15 +11,15 @@
<div class="flex"><!-- single turningpoint (for 'your comments')-->
<div class="flex-none">
<div class="pt-8 pr-2">
<img class="w-12 h-12 rounded-full border-2 border-slate-300" src="https://gravatar.com/avatar/{{ request.user|gravatar_sha }}?s=48&d=robohash" alt="{{ request.user|best_displayname }}">
<img class="w-12 h-12 rounded-full border-2 border-slate-300 dark:border-slate-600" src="https://gravatar.com/avatar/{{ request.user|gravatar_sha }}?s=48&d=robohash" alt="{{ request.user|best_displayname }}">
</div>
</div>
<div class="border-slate-300 border-2 rounded-md mt-6 flex-auto"><!-- the "your comments balloon" -->
<div class="border-slate-300 dark:border-slate-600 border-2 rounded-md mt-6 flex-auto"><!-- the "your comments balloon" -->
<div class="pl-4 flex triangle-left"><!-- 'header' row -->
<div class="mt-4 mb-4">
<span class="font-bold text-slate-800 italic">Add comment as manual annotation</span>
<span class="font-bold text-slate-800 dark:text-slate-100 italic">Add comment as manual annotation</span>
</div>
<div class="ml-auto flex"> <!-- 'header' row right side -->
<div class="p-4">
Now
@@ -27,12 +27,12 @@
</div>
</div>
<div class="border-t-2 pl-4 pr-4 pb-4 border-slate-300">{# 'body' part of the balloon (separated by a line) #}
<div class="border-t-2 pl-4 pr-4 pb-4 border-slate-300 dark:border-slate-600">{# 'body' part of the balloon (separated by a line) #}
<div class="mt-4">
<form action="{% url "history_comment_new" issue_pk=issue.id %}" method="post">
{% csrf_token %}
<textarea name="comment" placeholder="comments..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md w-full h-32" onkeypress="submitOnCtrlEnter(event)"></textarea>
<button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 mt-2 border-2 rounded-md hover:bg-slate-200 active:ring">Post comment</button>
<textarea name="comment" placeholder="comments..." class="bg-slate-50 dark:bg-slate-800 focus:border-cyan-500 dark:focus:border-cyan-400 focus:ring-cyan-200 dark:focus:ring-cyan-700 rounded-md w-full h-32" onkeypress="submitOnCtrlEnter(event)"></textarea>
<button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 mt-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring">Post comment</button>
</form>
</div>
</div>{# 'body' part of the balloon #}
@@ -45,39 +45,39 @@
<div class="flex-none">
<div class="pt-8 pr-2">
{% if turningpoint.user_id %}
<img class="w-12 h-12 rounded-full border-2 border-slate-300" src="https://gravatar.com/avatar/{{ turningpoint.user|gravatar_sha }}?s=48&d=robohash" alt="{{ turningpoint.user|best_displayname }}">
<img class="w-12 h-12 rounded-full border-2 border-slate-300 dark:border-slate-600" src="https://gravatar.com/avatar/{{ turningpoint.user|gravatar_sha }}?s=48&d=robohash" alt="{{ turningpoint.user|best_displayname }}">
{% else %}
<img class="w-12 h-12 rounded-full border-2 border-slate-300" src="{% static 'images/bugsink-logo.png' %}" alt="Bugsink">
<img class="w-12 h-12 rounded-full border-2 border-slate-300 dark:border-slate-600" src="{% static 'images/bugsink-logo.png' %}" alt="Bugsink">
{% endif %}
</div>
</div>
<div class="border-slate-300 border-2 rounded-md mt-6 flex-auto js-balloon"><!-- the "balloon" -->
<div class="border-slate-300 dark:border-slate-600 border-2 rounded-md mt-6 flex-auto js-balloon"><!-- the "balloon" -->
<div class="pl-4 flex triangle-left"><!-- 'header' row -->
<div class="mt-4 mb-4">
<span class="font-bold text-slate-800">{{ turningpoint.get_kind_display }}</span> by
<span class="font-bold text-slate-800">{% if turningpoint.user_id %}{{ turningpoint.user|best_displayname }}{% else %}Bugsink{% endif %}</span>
<span class="font-bold text-slate-800 dark:text-slate-100">{{ turningpoint.get_kind_display }}</span> by
<span class="font-bold text-slate-800 dark:text-slate-100">{% if turningpoint.user_id %}{{ turningpoint.user|best_displayname }}{% else %}Bugsink{% endif %}</span>
{% if turningpoint.user_id == request.user.id %}
<span class="text-slate-500 pl-1" onclick="toggleCommentEditable(this)" title="Edit comment">
<span class="text-slate-500 dark:text-slate-300 pl-1" onclick="toggleCommentEditable(this)" title="Edit comment">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4 inline">
<path fill-rule="evenodd" d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z" clip-rule="evenodd" />
</svg>
</span>
{% if turningpoint.kind == 100 %}
<span class="text-slate-500 pl-1" onclick="deleteComment('{% url "history_comment_delete" issue_pk=issue.id comment_pk=turningpoint.pk %}')" title="Delete comment">
<span class="text-slate-500 dark:text-slate-300 pl-1" onclick="deleteComment('{% url "history_comment_delete" issue_pk=issue.id comment_pk=turningpoint.pk %}')" title="Delete comment">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4 inline">
<path fill-rule="evenodd" d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z" clip-rule="evenodd" />
</svg>
</span>
{% endif %}
{% endif %}
</div>
<div class="ml-auto flex"> <!-- 'header' row right side -->
<div class="p-4 text-right">
@@ -87,7 +87,7 @@
</div>
{% if turningpoint.parsed_metadata or turningpoint.triggering_event_id or turningpoint.comment or turningpoint.user_id == request.user.id %} {# the last clause means: editable, hence space must be reserved #}
<div class="border-t-2 pl-4 pr-4 pb-4 border-slate-300">{# 'body' part of the balloon (separated by a line) #}
<div class="border-t-2 pl-4 pr-4 pb-4 border-slate-300 dark:border-slate-600">{# 'body' part of the balloon (separated by a line) #}
<div class="mt-4">
<div class="js-comment-plain">
{{ turningpoint.comment|linebreaksbr }}
@@ -97,8 +97,8 @@
<div class="js-comment-editable hidden">
<form action="{% url "history_comment_edit" issue_pk=issue.id comment_pk=turningpoint.pk %}" method="post">
{% csrf_token %}
<textarea name="comment" placeholder="comments..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md w-full h-32" onkeypress="submitOnCtrlEnter(event)">{{ turningpoint.comment }}</textarea>{# note: we don't actually use {{ form.comments }} here; this means the show-red-on-invalid loop won't work but since everything is valid and we haven't implemented the other parts of that loop that's fine #}
<button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 mt-2 border-2 rounded-md hover:bg-slate-200 active:ring">Update comment</button>
<textarea name="comment" placeholder="comments..." class="bg-slate-50 dark:bg-slate-800 focus:border-cyan-500 dark:focus:border-cyan-400 focus:ring-cyan-200 dark:focus:ring-cyan-700 rounded-md w-full h-32" onkeypress="submitOnCtrlEnter(event)">{{ turningpoint.comment }}</textarea>{# note: we don't actually use {{ form.comments }} here; this means the show-red-on-invalid loop won't work but since everything is valid and we haven't implemented the other parts of that loop that's fine #}
<button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 mt-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring">Update comment</button>
</form>
</div>
{% endif %}
@@ -150,7 +150,7 @@
{% if turningpoint.triggering_event_id %}
<div class="mt-4">
<a href="{% url "event_by_internal_id" event_pk=turningpoint.triggering_event_id %}" class="underline decoration-dotted font-bold text-slate-500">Triggering event</a>
<a href="{% url "event_by_internal_id" event_pk=turningpoint.triggering_event_id %}" class="underline decoration-dotted font-bold text-slate-500 dark:text-slate-300">Triggering event</a>
</div>
{% endif %}

View File

@@ -7,6 +7,23 @@
{% block content %}
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="hidden fixed inset-0 bg-slate-600 dark:bg-slate-900 bg-opacity-50 dark:bg-opacity-50 overflow-y-auto h-full w-full z-50 flex items-center justify-center">
<div class="relative p-6 border border-slate-300 dark:border-slate-600 w-96 shadow-lg rounded-md bg-white dark:bg-slate-900">
<div class="text-center m-4">
<h3 class="text-2xl font-semibold text-slate-800 dark:text-slate-100 mt-3 mb-4">Delete Issues</h3>
<div class="mt-4 mb-6">
<p class="text-slate-700 dark:text-slate-300">
Deleting an Issue is a permanent action and cannot be undone. It's typically better to resolve or mute an issue instead of deleting it, as this allows you to keep track of past issues and their resolutions.
</p>
</div>
<div class="flex items-center justify-center space-x-4 mb-4">
<button id="cancelDelete" class="text-cyan-500 dark:text-cyan-300 font-bold">Cancel</button>
<button id="confirmDelete" type="submit" class="font-bold py-2 px-4 rounded bg-red-500 dark:bg-red-700 text-white border-2 border-red-600 dark:border-red-400 hover:bg-red-600 dark:hover:bg-red-800 active:ring">Delete</button>
</div>
</div>
</div>
</div>
<div class="m-4">
@@ -18,33 +35,35 @@
</div>
{% endif %}
<div class="flex bg-slate-50 border-b-2 mt-4 items-end">
<div class="flex bg-slate-50 dark:bg-slate-800 border-b-2 mt-4 items-end">
<div class="flex">
<a href="{% url "issue_list_open" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 {% if state_filter == "open" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">Open</div></a>
<a href="{% url "issue_list_unresolved" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 {% if state_filter == "unresolved" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">Unresolved</div></a>
<a href="{% url "issue_list_muted" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 {% if state_filter == "muted" %}text-cyan-500 border-cyan-500 border-b-4{% else %}text-slate-500 hover:border-slate-400 hover:border-b-4{% endif %}">Muted</div></a>
<a href="{% url "issue_list_resolved" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 {% if state_filter == "resolved" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-slate-400 hover:border-b-4{% endif %}">Resolved</div></a>
<a href="{% url "issue_list_all" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 {% if state_filter == "all" %}text-cyan-500 border-cyan-500 border-b-4{% else %}text-slate-500 hover:border-slate-400 hover:border-b-4{% endif %}">All</div></a>
<a href="{% url "issue_list_open" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if state_filter == "open" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4 {% else %}text-slate-500 dark:text-slate-300 hover:border-b-4 hover:border-slate-400{% endif %}">Open</div></a>
<a href="{% url "issue_list_unresolved" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if state_filter == "unresolved" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4 {% else %}text-slate-500 dark:text-slate-300 hover:border-b-4 hover:border-slate-400{% endif %}">Unresolved</div></a>
<a href="{% url "issue_list_muted" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if state_filter == "muted" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4{% else %}text-slate-500 dark:text-slate-300 hover:border-slate-400 hover:border-b-4{% endif %}">Muted</div></a>
<a href="{% url "issue_list_resolved" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if state_filter == "resolved" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4 {% else %}text-slate-500 dark:text-slate-300 hover:border-slate-400 hover:border-b-4{% endif %}">Resolved</div></a>
<a href="{% url "issue_list_all" project_pk=project.id %}"><div class="p-4 font-bold hover:bg-slate-200 dark:hover:bg-slate-800 {% if state_filter == "all" %}text-cyan-500 dark:text-cyan-300 border-cyan-500 border-b-4{% else %}text-slate-500 dark:text-slate-300 hover:border-slate-400 hover:border-b-4{% endif %}">All</div></a>
</div>
<div class="ml-auto p-2">
<form action="." method="get">
<input type="text" name="q" value="{{ q }}" placeholder="search issues..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md"/>
<input type="text" name="q" value="{{ q }}" placeholder="search issues..." class="bg-slate-50 dark:bg-slate-800 focus:border-cyan-500 dark:focus:border-cyan-400 focus:ring-cyan-200 dark:focus:ring-cyan-700 rounded-md"/>
</form>
</div>
</div>
<div>
<form action="." method="post">
<form action="." method="post" id="issueForm">
{% csrf_token %}
<table class="w-full">
<thead> {# I briefly considered hiding this thead 'if not issue_list' but it actually looks worse; instead, we just hide that one checkbox #}
<thead> {# I briefly considered hiding this thead if there are no items but it actually looks worse; instead, we just hide that one checkbox #}
<tr class="bg-white border-slate-300 border-l-2 border-r-2">
<tr class="bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-600 border-l-2 border-r-2">
<td>
<div class="m-1 rounded-full hover:bg-slate-100 cursor-pointer" onclick="toggleContainedCheckbox(this); matchIssueCheckboxesStateToMain(this)">
{% if issue_list %}<input type="checkbox" class="bg-white border-cyan-800 text-cyan-500 focus:ring-cyan-200 m-4 cursor-pointer js-main-checkbox" onclick="event.stopPropagation(); matchIssueCheckboxesStateToMain(this.parentNode)"/>{% endif %}
<div class="m-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer" onclick="toggleContainedCheckbox(this); matchIssueCheckboxesStateToMain(this)">
{# the below sounds expensive, but this list is cached #}
{% if page_obj.object_list|length > 0 %}<input type="checkbox" class="bg-white dark:bg-slate-900 border-cyan-800 dark:border-cyan-400 text-cyan-500 dark:text-cyan-300 focus:ring-cyan-200 dark:focus:ring-cyan-700 m-4 cursor-pointer js-main-checkbox" onclick="event.stopPropagation(); matchIssueCheckboxesStateToMain(this.parentNode)"/>{% endif %}
</div>
</td>
<td class="w-full ml-0 pb-4 pt-4 pr-4 flex">
@@ -52,16 +71,16 @@
{% if disable_resolve_buttons %}
{# see issues/tests.py for why this is turned off ATM #}
{# <button class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md" name="action" value="reopen">Reopen</button> #}
{# <button class="font-bold text-slate-800 dark:text-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 active:ring rounded-md" name="action" value="reopen">Reopen</button> #}
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if project.has_releases %}
<button disabled class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-s-md" name="action" value="resolved_next">Resolved in next release</button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-s-md" name="action" value="resolved_next">Resolved in next release</button>
<button disabled class="font-bold text-slate-300 fill-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-t-2 border-b-2 rounded-e-md"><svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 fill-slate-300 dark:fill-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-t-2 border-b-2 rounded-e-md"><svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
{# we just hide the whole dropdown; this is the easiest implementation of not-showing the dropdown #}
{% else %}
<button disabled class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-md" name="action" value="resolved">Resolve</button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-md" name="action" value="resolved">Resolve</button>
{% endif %}
{% endspaceless %}
@@ -69,24 +88,24 @@
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if project.has_releases %}
{# 'by next' is shown even if 'by current' is also shown: just because you haven't seen 'by current' doesn't mean it's actually already solved; and in fact we show this option first precisely because we can always show it #}
<button class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-s-md" name="action" value="resolved_next">Resolved in next release</button>
{# 'by next' is shown even if 'by current' is also shown: just because you haven't seen 'by current' doesn't mean it's actually already solved; and in fact we show this option first precisely because we can always show it #}
<button class="font-bold text-slate-800 dark:text-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 active:ring rounded-s-md" name="action" value="resolved_next">Resolved in next release</button>
<div class="dropdown">
<button disabled {# disabled b/c clicking the dropdown does nothing - we have hover for that #} class="font-bold text-slate-800 fill-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-r-2 border-t-2 border-b-2 hover:bg-slate-200 active:ring rounded-e-md"><svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<button disabled {# disabled b/c clicking the dropdown does nothing - we have hover for that #} class="font-bold text-slate-800 dark:text-slate-100 fill-slate-800 dark:fill-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 border-r-2 border-t-2 border-b-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-e-md"><svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
{# note that we can depend on get_latest_release being available, because we're in the 'project.has_releases' branch #}
<div class="dropdown-content-right flex-col pl-2">
{# note that an if-statement ("issue.occurs_in_last_release") is missing here, because we're not on the level of a single issue #}
{# note that an if-statement ("issue.occurs_in_last_release") is missing here, because we're not on the level of a single issue #}
{# handling of that question (but per-issue, and after-click) is done in views.py, _q_for_invalid_for_action() #}
<button name="action" value="resolved_release:{{ project.get_latest_release.version }}" class="block self-stretch font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white hover:bg-slate-200 active:ring text-left whitespace-nowrap">Resolved in latest ({{ project.get_latest_release.get_short_version }})</button>
<button name="action" value="resolved_release:{{ project.get_latest_release.version }}" class="block self-stretch font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white dark:bg-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring text-left whitespace-nowrap">Resolved in latest ({{ project.get_latest_release.get_short_version }})</button>
</div>
</div>
</div>
{% else %}
<button class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md" name="action" value="resolve">Resolve</button>
<button class="font-bold text-slate-800 dark:text-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 active:ring rounded-md" name="action" value="resolve">Resolve</button>
{% endif %}
{% endspaceless %}
@@ -94,36 +113,46 @@
{% spaceless %}{# needed to avoid whitespace between the looks-like-one-buttons #}
{% if not disable_mute_buttons %}
<button name="action" value="mute" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-s-md">Mute</button>
<button name="action" value="mute" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-s-md">Mute</button>
{% else %}
<button disabled name="action" value="mute" class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-s-md">Mute</button>
<button disabled name="action" value="mute" class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 rounded-s-md">Mute</button>
{% endif %}
<div class="dropdown">
{% if not disable_mute_buttons %}
<button disabled {# disabled b/c clicking the dropdown does nothing - we have hover for that #} class="font-bold text-slate-500 fill-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 hover:bg-slate-200 active:ring">Mute for/until&nbsp;&nbsp;<svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<button disabled {# disabled b/c clicking the dropdown does nothing - we have hover for that #} class="font-bold text-slate-500 dark:text-slate-300 fill-slate-500 dark:fill-slate-500 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring">Mute for/until&nbsp;&nbsp;<svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<div class="dropdown-content-right flex-col">
{% for mute_option in mute_options %}
<button name="action" value="mute_{{ mute_option.for_or_until }}:{{ mute_option.period_name }},{{ mute_option.nr_of_periods }},{{ mute_option.gte_threshold }}" class="block self-stretch font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white hover:bg-slate-200 active:ring text-left whitespace-nowrap">{% if mute_option.for_or_until == "for" %}{{ mute_option.nr_of_periods }} {{ mute_option.period_name }}{% if mute_option.nr_of_periods != 1 %}s{% endif %}{% else %}{{ mute_option.gte_threshold }} events per {% if mute_option.nr_of_periods != 1%} {{ mute_option.nr_of_periods }} {{ mute_option.period_name }}s{% else %} {{ mute_option.period_name }}{% endif %}{% endif %}</button>
<button name="action" value="mute_{{ mute_option.for_or_until }}:{{ mute_option.period_name }},{{ mute_option.nr_of_periods }},{{ mute_option.gte_threshold }}" class="block self-stretch font-bold text-slate-500 dark:text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white dark:bg-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring text-left whitespace-nowrap">{% if mute_option.for_or_until == "for" %}{{ mute_option.nr_of_periods }} {{ mute_option.period_name }}{% if mute_option.nr_of_periods != 1 %}s{% endif %}{% else %}{{ mute_option.gte_threshold }} events per {% if mute_option.nr_of_periods != 1%} {{ mute_option.nr_of_periods }} {{ mute_option.period_name }}s{% else %} {{ mute_option.period_name }}{% endif %}{% endif %}</button>
{% endfor %}
</div>
{% else %}
<button disabled class="font-bold text-slate-300 fill-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2">Mute for/until&nbsp;&nbsp;<svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 fill-slate-300 dark:fill-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2">Mute for/until&nbsp;&nbsp;<svg xmlns="http://www.w3.org/2000/svg" fill="full" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 inline"><path d="M6.5,8.5l6,7l6-7H6.5z"/></svg></button>
{# we just hide the whole dropdown; this is the easiest implementation of not-showing the dropdown when the issue is already muted #}
{% endif %}
</div>
</div>
{% if not disable_unmute_buttons %}
<button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 hover:bg-slate-200 active:ring rounded-e-md" name="action" value="unmute">Unmute</button>
<button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-e-md" name="action" value="unmute">Unmute</button>
{% else %}
<button disabled class="font-bold text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 rounded-e-md" name="action" value="unmute">Unmute</button>
<button disabled class="font-bold text-slate-300 dark:text-slate-600 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 border-r-2 border-b-2 border-t-2 rounded-e-md" name="action" value="unmute">Unmute</button>
{% endif %}
<div class="dropdown">
<button disabled {# disabled b/c clicking the dropdown does nothing - we have hover for that #} class="font-bold text-slate-500 fill-slate-500 border-slate-300 ml-2 pl-4 pr-4 pb-2 pt-2 border-2 hover:bg-slate-200 active:ring rounded-md">...</button>
<div class="dropdown-content-right flex-col">
<button type="button" onclick="showDeleteConfirmation()" class="block self-stretch font-bold text-red-500 dark:text-slate-300 border-slate-300 pl-4 pr-4 pb-2 pt-2 border-l-2 border-r-2 border-b-2 bg-white dark:bg-slate-900 hover:bg-red-50 dark:hover:bg-red-800 active:ring text-left whitespace-nowrap">Delete</button>
</div>
</div>
{% endspaceless %}
{# NOTE: "reopen" is not available in the UI as per the notes in issue_detail #}
{# only for resolved/muted items <button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-md">Reopen</button> #}
{# only for resolved/muted items <button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-md">Reopen</button> #}
</div>
</td>
@@ -132,15 +161,15 @@
</thead>
<tbody>
{% for issue in page_obj %}
<tr class="bg-slate-50 border-slate-300 border-2 ">
<tr class="bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600 border-2 ">
<td>
<div class="m-1 rounded-full hover:bg-slate-100 cursor-pointer" onclick="toggleContainedCheckbox(this); matchMainCheckboxStateToIssueCheckboxes()">
<input type="checkbox" {% if issue.id in unapplied_issue_ids %}checked{% endif %} name="issue_ids[]" value="{{ issue.id }}" class="bg-white border-cyan-800 text-cyan-500 focus:ring-cyan-200 m-4 cursor-pointer js-issue-checkbox" onclick="event.stopPropagation(); {# prevent the container's handler from undoing the default action #} matchMainCheckboxStateToIssueCheckboxes()"/>
<div class="m-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer" onclick="toggleContainedCheckbox(this); matchMainCheckboxStateToIssueCheckboxes()">
<input type="checkbox" {% if issue.id in unapplied_issue_ids %}checked{% endif %} name="issue_ids[]" value="{{ issue.id }}" class="bg-white dark:bg-slate-900 border-cyan-800 dark:border-cyan-400 text-cyan-500 dark:text-cyan-300 focus:ring-cyan-200 dark:focus:ring-cyan-700 m-4 cursor-pointer js-issue-checkbox" onclick="event.stopPropagation(); {# prevent the container's handler from undoing the default action #} matchMainCheckboxStateToIssueCheckboxes()"/>
</div>
</td>
<td class="w-full ml-0 pb-4 pt-4 pr-4">
<div>
<a href="/issues/issue/{{ issue.id }}/event/last/{% current_qs %}" class="text-cyan-500 fill-cyan-500 font-bold {% if issue.is_resolved %}italic{% endif %}">{% if issue.is_resolved %}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6 inline"><path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd" />
<a href="/issues/issue/{{ issue.id }}/event/last/{% current_qs %}" class="text-cyan-500 dark:text-cyan-300 fill-cyan-500 font-bold {% if issue.is_resolved %}italic{% endif %}">{% if issue.is_resolved %}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6 inline"><path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd" />
</svg>{% endif %}{% if issue.is_muted %}<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>&nbsp;&nbsp;{% endif %}{{ issue.title|truncatechars:100 }}</a>
@@ -156,19 +185,19 @@
</tr>
{% empty %}
<tr class="bg-slate-50 border-slate-300 border-2 ">
<tr class="bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600 border-2 ">
<td>
</td>
<td class="w-full ml-0 pb-4 pt-4 pr-4 text-center">
<div class="p-4 text-xl font-bold text-slate-800">
<div class="p-4 text-xl font-bold text-slate-800 dark:text-slate-100">
{% if q %}{# a single text is the catch-all for searching w/o results; 'seems enough' because one would generally only search after already having seen some issues (or not), i.e. having seen the relevant message as per below #}
No {{ state_filter }} issues found for "{{ q }}"
{% else %}
{% if state_filter == "open" %}
Congratulations! You have no open issues.
{% if project.digested_event_count == 0 %}
This might mean you have not yet <a class="text-cyan-500 font-bold" href="{% url "project_sdk_setup" project_pk=project.id %}">set up your SDK</a>.
This might mean you have not yet <a class="text-cyan-500 dark:text-cyan-300 font-bold" href="{% url "project_sdk_setup" project_pk=project.id %}">set up your SDK</a>.
{% endif %}
{% else %}
No {{ state_filter }} issues found.
@@ -200,29 +229,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6 text-slate-200"><path fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" /></svg>
{% endif %}
{% if page_obj.paginator.num_pages > 1 %}
Issues {{ page_obj.start_index|intcomma }}{{ page_obj.end_index|intcomma }} of {{ page_obj.paginator.count|intcomma }}
{% elif page_obj.paginator.count > 0 %}
{{ page_obj.paginator.count|intcomma }} Issues
{% if page_obj.object_list|length > 0 %}{# sounds expensive, but this list is cached #}
Issues {{ page_obj.start_index|intcomma }} {{ page_obj.end_index|intcomma }}
{% else %}
{% if page_obj.number > 1 %}
Less than {{ page_obj.start_index }} Issues {# corresponds to the 1/250 case of having an exactly full page and navigating to an empty page after that #}
{% else %}
0 Issues
{% endif %}
{% endif %}
{% if page_obj.has_next %}
<a href="?{% add_to_qs page=page_obj.next_page_number %}" class="inline-flex" title="Next page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
</a>
<a href="?{% add_to_qs page=page_obj.paginator.num_pages %}" class="inline-flex" title="Last page">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6"><path fill-rule="evenodd" d="M12.78 7.595a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06l3.25 3.25Zm-8.25-3.25 3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06Z" clip-rule="evenodd" /></svg>
</a>
{% else %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6 text-slate-200"><path fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-6 h-6 text-slate-200"><path fill-rule="evenodd" d="M12.78 7.595a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06l3.25 3.25Zm-8.25-3.25 3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06l2.72-2.72-2.72-2.72a.75.75 0 0 1 1.06-1.06Z" clip-rule="evenodd" /></svg>
{% endif %}
</div>
<div class="flex ml-auto justify-end">{# the div with a few project-related icons (pjt-members, pjt-settings, my settings, dsn) on the lower RHS #}
{% if not app_settings.SINGLE_USER %}{% if member.is_admin or request.user.is_superuser %}
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer text-slate-700" onclick="followContainedLink(this);" title="Project members">
<div class="rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 p-2 cursor-pointer text-slate-700" onclick="followContainedLink(this);" title="Project members">
<a href="{% url 'project_members' project_pk=project.id %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
@@ -232,7 +261,7 @@
{% endif %}{% endif %}
{% if member.is_admin or request.user.is_superuser %}
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer text-slate-700" onclick="followContainedLink(this);" title="Project settings">
<div class="rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 p-2 cursor-pointer text-slate-700" onclick="followContainedLink(this);" title="Project settings">
<a href="{% url 'project_edit' project_pk=project.id %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
@@ -243,7 +272,7 @@
{% endif %}
{# member-existance is implied if you can see this page #}
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer text-slate-700" onclick="followContainedLink(this);" title="Project membership (notification settings){# verbose! #}">
<div class="rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 p-2 cursor-pointer text-slate-700" onclick="followContainedLink(this);" title="Project membership (notification settings){# verbose! #}">
<a href="{% url 'project_member_settings' project_pk=project.id user_pk=request.user.id %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 0 1-1.44-4.282m3.102.069a18.03 18.03 0 0 1-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 0 1 8.835 2.535M10.34 6.66a23.847 23.847 0 0 0 8.835-2.535m0 0A23.74 23.74 0 0 0 18.795 3m.38 1.125a23.91 23.91 0 0 1 1.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 0 0 1.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 0 1 0 3.46" />
@@ -252,7 +281,7 @@
</div>
{# member-existance is implied if you can see this page #}
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer text-slate-700" onclick="followContainedLink(this);" title="SDK Setup (connect app)">
<div class="rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 p-2 cursor-pointer text-slate-700" onclick="followContainedLink(this);" title="SDK Setup (connect app)">
<a href="{% url 'project_sdk_setup' project_pk=project.id %}">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="size-8">
<path d="M202.7,259.7l-31.5,31.5c-5.6,5.6-8.6,13-8.6,20.9c0,7.9,3.1,15.3,8.6,20.9l6.1,6.1l-3.7,3.7c-11.1,11.1-29.2,11.1-40.4,0
@@ -279,5 +308,36 @@
{% endblock %}
{% block extra_js %}
<script>
const deleteButton = document.getElementById('');
const confirmationBox = document.getElementById('deleteModal');
const confirmDelete = document.getElementById('confirmDelete');
const cancelDelete = document.getElementById('cancelDelete');
const form = document.getElementById('issueForm');
let actionInput = null;
function showDeleteConfirmation() {
confirmationBox.style.display = 'flex';
}
cancelDelete.addEventListener('click', () => {
confirmationBox.style.display = 'none';
});
confirmDelete.addEventListener('click', () => {
// Add hidden input only for this submission
if (!actionInput) {
actionInput = document.createElement('input');
actionInput.type = 'hidden';
actionInput.name = 'action';
actionInput.value = 'delete';
form.appendChild(actionInput);
}
form.submit();
});
</script>
<script src="{% static 'js/issue_list.js' %}"></script>
{% endblock %}

View File

@@ -28,23 +28,25 @@
{% for exception in exceptions %}
<div class="flex">
<div class="flex items-start flex-col-reverse lg:flex-row">
<div class="overflow-hidden">
{% if forloop.counter0 == 0 %}
<div class="italic">{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} total{% if q %} — {{ event_qs_count|intcomma }} found by search{% endif %})</div>
<div class="italic text-ellipsis whitespace-nowrap overflow-hidden">{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} total{% if q %} — {{ event_qs_count|intcomma }} found by search{% endif %})</div>
{% endif %}
<h1 class="text-2xl font-bold {% if forloop.counter0 > 0 %}mt-4{% endif %} text-ellipsis whitespace-nowrap overflow-hidden">{{ exception.type }}</h1>
<div class="text-lg mb-4 text-ellipsis whitespace-nowrap overflow-hidden">{{ exception.value }}</div>
</div>
{% if forloop.counter0 == 0 %}
<div class="ml-auto flex-none">
<div class="flex place-content-end">
<button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring" onclick="showAllFrames()">Show all</button>
<button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring" onclick="showInAppFrames()">Show in-app</button>
<button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring" onclick="showRaisingFrame()">Show raise</button>
<button class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 active:ring" onclick="hideAllFrames()">Collapse all</button>
<div class="ml-auto flex flex-none flex-col-reverse 3xl:flex-row"> {# container of 2 divs: one for buttons, one for event-nav; on smaller screens these are 2 rows; on bigger they are side-by-side #}
<div class="flex place-content-end self-stretch pt-2 3xl:pt-0 {# <= to keep the buttons apart #} pb-4 lg:pb-0 {# <= to keep the buttons & h1-block apart #}">
<button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring" onclick="showAllFrames()">Show all</button>
<button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring" onclick="showInAppFrames()">Show in-app</button>
<button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring" onclick="showRaisingFrame()">Show raise</button>
<button class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-1 pt-1 mr-2 border-2 rounded-md hover:bg-slate-200 dark:hover:bg-slate-800 active:ring" onclick="hideAllFrames()">Collapse all</button>
</div>
<div class="flex place-content-end">
{% include "issues/_event_nav.html" %}
</div>
</div>
@@ -53,15 +55,15 @@
{% for frame in exception.stacktrace.frames %}
{% with frame=frame|pygmentize:event.platform %}
<div class="bg-white w-full font-mono"> {# per frame div #}
<div class="bg-white dark:bg-slate-700 w-full font-mono"> {# per frame div #}
{% if frame.raise_point %}<span id="raise"></span>{% endif %}
{% if frame.in_app %}<span id="in-app"></span>{% endif %}
{% if forloop.first and forloop.parentloop.first %}<span id="first-frame"></span>{% endif %}
<div class="flex pl-4 pt-2 pb-2 border-b-2 {% if forloop.first %}border-t-2{% endif %} bg-slate-100 border-slate-400 cursor-pointer" onclick="toggleFrameVisibility(this)"> {# per frame header div #}
<div class="flex pl-4 pt-2 pb-2 border-b-2 {% if forloop.first %}border-t-2{% endif %} bg-slate-100 dark:bg-slate-700 border-slate-400 cursor-pointer" onclick="toggleFrameVisibility(this)"> {# per frame header div #}
<div> {# filename, function, lineno #}
<div class="text-ellipsis overflow-hidden"> {# filename, function, lineno #}
{% if frame.in_app %}
<span class="font-bold">{{ frame.filename }}</span>{% if frame.function %} in <span class="font-bold">{{ frame.function }}</span>{% endif %}{% if frame.lineno %} line <span class="font-bold">{{ frame.lineno }}</span>{% endif %}.
{% else %}
@@ -72,13 +74,13 @@
<div class="ml-auto pr-4">{# indicator for frame's position in stacktrace #}
{% if stack_of_plates and forloop.first or not stack_of_plates and forloop.last %}
{% if stack_of_plates and forloop.parentloop.first or not stack_of_plates and forloop.parentloop.last %}
<span class="bg-slate-200 pl-2 pr-2 pt-1 pb-1 rounded-md whitespace-nowrap">raise {{ exception.type }}</span>
<span class="bg-slate-200 dark:bg-slate-800 pl-2 pr-2 pt-1 pb-1 rounded-md whitespace-nowrap">raise {{ exception.type }}</span>
{% else %}
<span class="bg-slate-200 pl-2 pr-2 pt-1 pb-1 rounded-md whitespace-nowrap">raise {{ exception.type }} (handled)</span>
<span class="bg-slate-200 dark:bg-slate-800 pl-2 pr-2 pt-1 pb-1 rounded-md whitespace-nowrap">raise {{ exception.type }} (handled)</span>
{% endif %}
{% elif stack_of_plates and forloop.last or not stack_of_plates and forloop.first %} {# strictly speaking, not actually "else", but to avoid clutter we hide 'outermost' info when this is also the raise-point #}
{% if stack_of_plates and forloop.parentloop.first or not stack_of_plates and forloop.parentloop.last %}
<span class="bg-slate-200 pl-2 pr-2 pt-1 pb-1 rounded-md whitespace-nowrap">→ begin</span>
<span class="bg-slate-200 dark:bg-slate-800 pl-2 pr-2 pt-1 pb-1 rounded-md whitespace-nowrap">→ begin</span>
{% else %}
{% comment %}I find it (quite too) hard to come up with a good name for this type of frame that is both short and clear. Thoughts so fare were:
* try...
@@ -90,7 +92,7 @@
* "divergence w/ main exception"
* first unique frame
{% endcomment %}
<span class="bg-slate-200 pl-2 pr-2 pt-1 pb-1 rounded-md whitespace-nowrap">try…</span>
<span class="bg-slate-200 dark:bg-slate-800 pl-2 pr-2 pt-1 pb-1 rounded-md whitespace-nowrap">try…</span>
{% endif %}
{% endif %}
@@ -103,18 +105,18 @@
</div>
</div> {# per frame header div #}
<div class="js-frame-details {% if frame.in_app %}js-in-app{% endif %} border-slate-400 overflow-hidden transition-all {% if stack_of_plates and forloop.parentloop.first and forloop.first or not stack_of_plates and forloop.parentloop.last and forloop.last %}js-raising-frame{% endif %}"
<div class="js-frame-details {% if frame.in_app %}js-in-app{% endif %} border-slate-400 overflow-hidden transition-all {% if stack_of_plates and forloop.parentloop.first and forloop.first or not stack_of_plates and forloop.parentloop.last and forloop.last %}js-raising-frame{% endif %}"
{% if not frame.raise_point %}data-collapsed="true" style="height: 0px"{% endif %}> {# collapsable part #}
<div class="pl-6 pr-6 {% if not forloop.last %}border-b-2 border-slate-400{% endif %}">{# convience div for padding & border; the border is basically the top-border of the next header #}
{% if "context_line" in frame and frame.context_line is not None %}
<div class="bg-slate-50 syntax-coloring mt-6 mb-6">{# code listing #}
<div class="bg-slate-50 dark:bg-slate-800 syntax-coloring mt-6 mb-6">{# code listing #}
{# the spread-out pX-6 in this code is intentional to ensure the padding is visible when scrolling to the right, and not visible when scrolling is possible (i.e. the text is cut-off awkwardly to hint at scrolling #}
<ol class="list-decimal overflow-x-auto list-inside pt-6 pb-6 {% if frame|firstlineno is None %}list-none{% endif %}" start="{{ frame|firstlineno }}">
{% for line in frame.pre_context %}<li class="pl-6"><div class="whitespace-pre w-full inline pr-6">{{ line }} {# leave space to avoid collapse #}</div></li>{% endfor %}
{# the gradient is a workaround, because I can't get a full-width elem going here inside the overflow #}
{# when some other line is overflowing. Using the gradient hides this fact (it happens to also look good) #}
<li class="pl-6 bg-gradient-to-r from-slate-300 font-bold w-full"><div class="whitespace-pre w-full inline pr-6">{{ frame.context_line }} {# leave space to avoid collapse #}</div></li>
<li class="pl-6 bg-gradient-to-r from-slate-300 dark:from-slate-950 font-bold w-full"><div class="whitespace-pre w-full inline pr-6">{{ frame.context_line }} {# leave space to avoid collapse #}</div></li>
{% for line in frame.post_context %}<li class="pl-6"><div class="whitespace-pre w-full inline pr-6">{{ line }} {# leave space to avoid collapse #}</div></li>{% endfor %}
</ol>
</div>
@@ -123,13 +125,13 @@
{% if frame.vars %}
<div class="mt-4 mb-6">{# variables #}
<div class="flex">
<div class="w-1/3 pt-2 font-bold border-b-2 border-slate-500 pl-4">Variable</div>
<div class="w-2/3 pt-2 font-bold border-b-2 border-slate-500 pr-4">Value</div>
<div class="w-1/3 pt-2 font-bold border-b-2 border-slate-500 dark:border-slate-400 pl-4">Variable</div>
<div class="w-2/3 pt-2 font-bold border-b-2 border-slate-500 dark:border-slate-400 pr-4">Value</div>
</div>
{% for var, value in frame.vars|items %}
<div class="flex">
<div class="w-1/3 pl-4 {% if not forloop.last or frame.vars|incomplete %}border-b-2 border-dotted border-slate-300{% endif %}">{{ var }}</div>
<div class="w-2/3 pr-4 {% if not forloop.last or frame.vars|incomplete %} border-b-2 border-dotted border-slate-300{% endif %}">{{ value|format_var }}</div>
<div class="w-1/3 pl-4 {% if not forloop.last or frame.vars|incomplete %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ var }}</div>
<div class="w-2/3 pr-4 {% if not forloop.last or frame.vars|incomplete %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ value|format_var }}</div>
</div>
{% endfor %}
{% if frame.vars|incomplete %}
@@ -144,7 +146,11 @@
{% if "context_line" not in frame or frame.context_line is None %}{% if not frame.vars %}{# nested ifs as a subsitute for brackets-in-templates #}
<div class="mt-6 mb-6 italic">
No code context or variables available for this frame.
{% if frame.debug_id %}{# only in the no-vars-either case to avoid excessive if-nesting (at the cost of completeness, but "will yes-vars, broken debug_id even be a case? For now we hope not) #}
No sourcemaps found for Debug ID {{ frame.debug_id }}
{% else %}
No code context or variables available for this frame.
{% endif %}
</div>
{% endif %}{% endif %}

View File

@@ -8,11 +8,11 @@
<h1 id="{{ issuetags.0.key.key }}" class="text-2xl font-bold mt-4">{{ issuetags.0.key.key }}:</h1>
<div class="mb-6">
{% for issuetag in issuetags %}
<div class="flex {% if forloop.first %}border-slate-300 border-t-2{% endif %}">
<div class="w-2/3 {% if not forloop.last %}border-b-2 border-dotted border-slate-300{% endif %}">{{ issuetag.value.value }}</div>
<div class="w-1/6 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ issuetag.pct }}%</div>
<div class="w-1/6 {% if not forloop.last %} border-b-2 border-dotted border-slate-300{% endif %} font-mono">{{ issuetag.count }} events</div>
{% for issuetag in issuetags %}
<div class="flex {% if forloop.first %}border-slate-300 dark:border-slate-600 border-t-2{% endif %}">
<div class="w-2/3 {% if not forloop.last %}border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %}">{{ issuetag.value.value }}</div>
<div class="w-1/6 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ issuetag.pct }}%</div>
<div class="w-1/6 {% if not forloop.last %} border-b-2 border-dotted border-slate-300 dark:border-slate-600{% endif %} font-mono">{{ issuetag.count }} events</div>
</div>
{% endfor %}
</div>

View File

@@ -13,8 +13,10 @@ from django.test import TestCase as DjangoTestCase
from django.contrib.auth import get_user_model
from django.test import tag
from django.conf import settings
from django.apps import apps
from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase
from bugsink.utils import get_model_topography
from projects.models import Project, ProjectMembership
from releases.models import create_release_if_needed
from events.factories import create_event
@@ -23,11 +25,14 @@ from compat.dsn import get_header_value
from events.models import Event
from ingest.views import BaseIngestAPIView
from issues.factories import get_or_create_issue
from tags.models import store_tags
from tags.tasks import vacuum_tagvalues
from .models import Issue, IssueStateManager
from .models import Issue, IssueStateManager, TurningPoint, TurningPointKind
from .regressions import is_regression, is_regression_2, issue_is_regression
from .factories import denormalized_issue_fields
from .utils import get_issue_grouper_for_data
from .tasks import get_model_topography_with_issue_override
User = get_user_model()
@@ -351,12 +356,11 @@ class MuteUnmuteTestCase(TransactionTestCase):
def test_unmute_simple_case(self, send_unmute_alert):
project = Project.objects.create()
issue = Issue.objects.create(
project=project,
unmute_on_volume_based_conditions='[{"period": "day", "nr_of_periods": 1, "volume": 1}]',
is_muted=True,
**denormalized_issue_fields(),
)
issue, _ = get_or_create_issue(project)
issue.unmute_on_volume_based_conditions = '[{"period": "day", "nr_of_periods": 1, "volume": 1}]'
issue.is_muted = True
issue.save()
event = create_event(project, issue)
BaseIngestAPIView.count_issue_periods_and_act_on_it(issue, event, datetime.now(timezone.utc))
@@ -371,15 +375,14 @@ class MuteUnmuteTestCase(TransactionTestCase):
def test_unmute_two_simultaneously_should_lead_to_one_alert(self, send_unmute_alert):
project = Project.objects.create()
issue = Issue.objects.create(
project=project,
unmute_on_volume_based_conditions='''[
issue, _ = get_or_create_issue(project)
issue. unmute_on_volume_based_conditions = '''[
{"period": "day", "nr_of_periods": 1, "volume": 1},
{"period": "month", "nr_of_periods": 1, "volume": 1}
]''',
is_muted=True,
**denormalized_issue_fields(),
)
]'''
issue.is_muted = True
issue.save()
event = create_event(project, issue)
BaseIngestAPIView.count_issue_periods_and_act_on_it(issue, event, datetime.now(timezone.utc))
@@ -665,3 +668,98 @@ class GroupingUtilsTestCase(DjangoTestCase):
def test_fingerprint_with_default(self):
self.assertEqual("Log Message: <no log message> ⋄ <no transaction> ⋄ fixed string",
get_issue_grouper_for_data({"fingerprint": ["{{ default }}", "fixed string"]}))
class IssueDeletionTestCase(TransactionTestCase):
def setUp(self):
super().setUp()
self.project = Project.objects.create(name="Test Project", stored_event_count=1) # 1, in prep. of the below
self.issue, _ = get_or_create_issue(self.project)
self.event = create_event(self.project, issue=self.issue)
TurningPoint.objects.create(
project=self.project,
issue=self.issue, triggering_event=self.event, timestamp=self.event.ingested_at,
kind=TurningPointKind.FIRST_SEEN)
self.event.never_evict = True
self.event.save()
store_tags(self.event, self.issue, {"foo": "bar"})
def test_delete_issue(self):
models = [apps.get_model(app_label=s.split('.')[0], model_name=s.split('.')[1].lower()) for s in [
'events.Event', 'issues.Grouping', 'issues.TurningPoint', 'tags.EventTag', 'issues.Issue', 'tags.IssueTag',
'tags.TagValue', # TagValue 'feels like' a vacuum_model (FKs reversed) but is cleaned up in `prune_orphans`
]]
# see the note in `prune_orphans` about TagKey to understand why it's special.
vacuum_models = [apps.get_model(app_label=s.split('.')[0], model_name=s.split('.')[1].lower())
for s in ['tags.TagKey',]]
for model in models + vacuum_models:
# test-the-test: make sure some instances of the models actually exist after setup
self.assertTrue(model.objects.exists(), f"Some {model.__name__} should exist")
# assertNumQueries() is brittle and opaque. But at least the brittle part is quick to fix (a single number) and
# provides a canary for performance regressions.
# correct for bugsink/transaction.py's select_for_update for non-sqlite databases
correct_for_select_for_update = 1 if 'sqlite' not in settings.DATABASES['default']['ENGINE'] else 0
with self.assertNumQueries(19 + correct_for_select_for_update):
self.issue.delete_deferred()
# tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly
for model in models:
self.assertFalse(model.objects.exists(), f"No {model.__name__}s should exist after issue deletion")
for model in vacuum_models:
# 'should' in quotes because this isn't so because we believe it's better if they did, but because the
# code currently does not delete them.
self.assertTrue(model.objects.exists(), f"Some {model.__name__}s 'should' exist after issue deletion")
self.assertEqual(0, Project.objects.get().stored_event_count)
vacuum_tagvalues()
# tests run w/ TASK_ALWAYS_EAGER, so any "delayed" (recursive) calls can be expected to have run
for model in vacuum_models:
self.assertFalse(model.objects.exists(), f"No {model.__name__}s should exist after vacuuming")
def test_dependency_graphs(self):
# tests for an implementation detail of defered deletion, namely 1 test that asserts what the actual
# model-topography is, and one test that shows how we manually override it; this is to trigger a failure when
# the topology changes (and forces us to double-check that the override is still correct).
orig = get_model_topography()
override = get_model_topography_with_issue_override()
def walk(topo, model_name):
results = []
for model, fk_name in topo[model_name]:
results.append((model, fk_name))
results.extend(walk(topo, model._meta.label))
return results
self.assertEqual(walk(orig, 'issues.Issue'), [
(apps.get_model('issues', 'Grouping'), 'issue'),
(apps.get_model('events', 'Event'), 'grouping'),
(apps.get_model('issues', 'TurningPoint'), 'triggering_event'),
(apps.get_model('tags', 'EventTag'), 'event'),
(apps.get_model('issues', 'TurningPoint'), 'issue'),
(apps.get_model('events', 'Event'), 'issue'),
(apps.get_model('issues', 'TurningPoint'), 'triggering_event'),
(apps.get_model('tags', 'EventTag'), 'event'),
(apps.get_model('tags', 'EventTag'), 'issue'),
(apps.get_model('tags', 'IssueTag'), 'issue'),
])
self.assertEqual(walk(override, 'issues.Issue'), [
(apps.get_model('issues', 'TurningPoint'), 'issue'),
(apps.get_model('tags', 'EventTag'), 'issue'),
(apps.get_model('events', 'Event'), 'issue'),
(apps.get_model('issues', 'Grouping'), 'issue'),
(apps.get_model('tags', 'IssueTag'), 'issue'),
])

View File

@@ -54,7 +54,8 @@ urlpatterns = [
path('issue/<uuid:issue_pk>/event/<first-last:nav>/', issue_event_stacktrace, name="event_stacktrace"),
path('issue/<uuid:issue_pk>/event/<first-last:nav>/details/', issue_event_details, name="event_details"),
path('issue/<uuid:issue_pk>/event/<first-last:nav>/breadcrumbs/', issue_event_details, name="event_breadcrumbs"),
path(
'issue/<uuid:issue_pk>/event/<first-last:nav>/breadcrumbs/', issue_event_breadcrumbs, name="event_breadcrumbs"),
path('issue/<uuid:issue_pk>/tags/', issue_tags),
path('issue/<uuid:issue_pk>/history/', issue_history),

View File

@@ -151,7 +151,9 @@ def get_issue_grouper_for_data(data, calculated_type=None, calculated_value=None
if fingerprint:
return "".join([
default_issue_grouper(calculated_type, calculated_value, transaction) if part == "{{ default }}" else part
(default_issue_grouper(calculated_type, calculated_value, transaction)
if part == "{{ default }}"
else str(part))
for part in fingerprint
])

View File

@@ -15,6 +15,7 @@ from django.http import Http404
from django.core.paginator import Paginator, Page
from django.db.utils import OperationalError
from django.conf import settings
from django.utils.functional import cached_property
from sentry.utils.safe import get_path
from sentry_sdk_extensions import capture_or_log_exception
@@ -34,7 +35,7 @@ from tags.search import search_issues, search_events, search_events_optimized
from .models import Issue, IssueQuerysetStateManager, IssueStateManager, TurningPoint, TurningPointKind
from .forms import CommentForm
from .utils import get_values, get_main_exception
from events.utils import annotate_with_meta, apply_sourcemaps
from events.utils import annotate_with_meta, apply_sourcemaps, get_sourcemap_images
logger = logging.getLogger("bugsink.issues")
@@ -87,6 +88,35 @@ class KnownCountPaginator(EagerPaginator):
return self._count
class UncountablePage(Page):
"""The Page subclass to be used with UncountablePaginator."""
@cached_property
def has_next(self):
# hack that works 249/250 times: if the current page is full, we have a next page
return len(self.object_list) == self.paginator.per_page
@cached_property
def end_index(self):
return (self.paginator.per_page * (self.number - 1)) + len(self.object_list)
class UncountablePaginator(EagerPaginator):
"""optimization: counting is too expensive; to be used in a template w/o .count and .last"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _get_page(self, *args, **kwargs):
object_list = args[0]
object_list = list(object_list)
return UncountablePage(object_list, *(args[1:]), **kwargs)
@property
def count(self):
return 1_000_000_000 # big enough to be bigger than what you can click through or store in the DB.
def _request_repr(parsed_data):
if "request" not in parsed_data:
return ""
@@ -98,6 +128,10 @@ def _is_valid_action(action, issue):
"""We take the 'strict' approach of complaining even when the action is simply a no-op, because you're already in
the desired state."""
if action == "delete":
# any type of issue can be deleted
return True
if issue.is_resolved:
# any action is illegal on resolved issues (as per our current UI)
return False
@@ -123,6 +157,10 @@ def _is_valid_action(action, issue):
def _q_for_invalid_for_action(action):
"""returns a Q obj of issues for which the action is not valid."""
if action == "delete":
# delete is always valid, so we don't want any issues to be returned, https://stackoverflow.com/a/39001190
return Q(pk__in=[])
illegal_conditions = Q(is_resolved=True) # any action is illegal on resolved issues (as per our current UI)
if action.startswith("resolved_release:"):
@@ -139,7 +177,10 @@ def _q_for_invalid_for_action(action):
def _make_history(issue_or_qs, action, user):
if action == "resolve":
if action == "delete":
return # we're about to delete the issue, so no history is needed (nor possible)
elif action == "resolve":
kind = TurningPointKind.RESOLVED
elif action.startswith("resolved"):
kind = TurningPointKind.RESOLVED
@@ -180,10 +221,13 @@ def _make_history(issue_or_qs, action, user):
now = timezone.now()
if isinstance(issue_or_qs, Issue):
TurningPoint.objects.create(
project=issue_or_qs.project,
issue=issue_or_qs, kind=kind, user=user, metadata=json.dumps(metadata), timestamp=now)
else:
TurningPoint.objects.bulk_create([
TurningPoint(issue=issue, kind=kind, user=user, metadata=json.dumps(metadata), timestamp=now)
TurningPoint(
project_id=issue.project_id, issue=issue, kind=kind, user=user, metadata=json.dumps(metadata),
timestamp=now)
for issue in issue_or_qs
])
@@ -219,6 +263,8 @@ def _apply_action(manager, issue_or_qs, action, user):
}]))
elif action == "unmute":
manager.unmute(issue_or_qs)
elif action == "delete":
manager.delete(issue_or_qs)
def issue_list(request, project_pk, state_filter="open"):
@@ -262,20 +308,24 @@ def _issue_list_pt_2(request, project, state_filter, unapplied_issue_ids):
}
issue_list = d_state_filter[state_filter](
Issue.objects.filter(project=project)
Issue.objects.filter(project=project, is_deleted=False)
).order_by("-last_seen")
if request.GET.get("q"):
issue_list = search_issues(project, issue_list, request.GET["q"])
paginator = EagerPaginator(issue_list, 250)
paginator = UncountablePaginator(issue_list, 250)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
try:
member = ProjectMembership.objects.get(project=project, user=request.user)
except ProjectMembership.DoesNotExist:
member = None # this can happen if the user is superuser (as per `project_membership_required` decorator)
return render(request, "issues/issue_list.html", {
"project": project,
"member": ProjectMembership.objects.get(project=project, user=request.user),
"issue_list": issue_list,
"member": member,
"state_filter": state_filter,
"mute_options": GLOBAL_MUTE_OPTIONS,
@@ -558,6 +608,7 @@ def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=No
("ingested at", _date_with_milis_html(event.ingested_at)),
("digested at", _date_with_milis_html(event.digested_at)),
("digest order", event.digest_order),
("remote_addr", event.remote_addr),
]
logentry_info = []
@@ -578,6 +629,11 @@ def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=No
logentry_key = "logentry" if "logentry" in parsed_data else "message"
if isinstance(parsed_data.get(logentry_key), dict):
# NOTE: event.schema.json says "If `message` and `params` are given, Sentry will attempt to backfill
# `formatted` if empty." but we don't do that yet.
if parsed_data.get(logentry_key, {}).get("formatted"):
logentry_info.append(("formatted", parsed_data[logentry_key]["formatted"]))
if parsed_data.get(logentry_key, {}).get("message"):
logentry_info.append(("message", parsed_data[logentry_key]["message"]))
@@ -589,7 +645,7 @@ def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=No
for param_k, param_v in params.items():
logentry_info.append((param_k, param_v))
elif isinstance(parsed_data.get(logentry_key), str):
elif isinstance(parsed_data.get(logentry_key), str): # robust for top-level as str (see #55)
logentry_info.append(("message", parsed_data[logentry_key]))
key_info += [
@@ -603,6 +659,15 @@ def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=No
contexts = get_contexts_enriched_with_ua(parsed_data)
try:
sourcemaps_images = get_sourcemap_images(parsed_data)
except Exception as e:
if settings.DEBUG or settings.I_AM_RUNNING == "TEST":
# when developing/testing, I _do_ want to get notified
raise
# 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)
return render(request, "issues/event_details.html", {
"tab": "event-details",
"this_view": "event_details",
@@ -616,6 +681,7 @@ def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=No
"logentry_info": logentry_info,
"deployment_info": deployment_info,
"contexts": contexts,
"sourcemaps_images": sourcemaps_images,
"mute_options": GLOBAL_MUTE_OPTIONS,
"q": request.GET.get("q", ""),
# event_qs_count is not used when there is no q, so no need to calculate it in that case
@@ -728,6 +794,7 @@ def history_comment_new(request, issue):
# think that's amount of magic to have: it still allows one to erase comments (possibly for non-manual
# kinds) but it saves you from what is obviously a mistake (without complaining with a red box or something)
TurningPoint.objects.create(
project=issue.project,
issue=issue, kind=TurningPointKind.MANUAL_ANNOTATION, user=request.user,
comment=form.cleaned_data["comment"],
timestamp=timezone.now())

View File

@@ -1,9 +1,17 @@
from django.contrib import admin
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
from admin_auto_filters.filters import AutocompleteFilter
from bugsink.transaction import immediate_atomic
from .models import Project, ProjectMembership
csrf_protect_m = method_decorator(csrf_protect)
class ProjectFilter(AutocompleteFilter):
title = 'Project'
field_name = 'project'
@@ -31,9 +39,8 @@ class ProjectAdmin(admin.ModelAdmin):
list_display = [
'name',
'dsn',
'alert_on_new_issue',
'alert_on_regression',
'alert_on_unmute',
'digested_event_count',
'stored_event_count',
]
readonly_fields = [
@@ -47,6 +54,31 @@ class ProjectAdmin(admin.ModelAdmin):
'slug': ['name'],
}
def get_deleted_objects(self, objs, request):
to_delete = list(objs) + ["...all its related objects... (delayed)"]
model_count = {
Project: len(objs),
}
perms_needed = set()
protected = []
return to_delete, model_count, perms_needed, protected
def delete_queryset(self, request, queryset):
# NOTE: not the most efficient; it will do for a first version.
with immediate_atomic():
for obj in queryset:
obj.delete_deferred()
def delete_model(self, request, obj):
with immediate_atomic():
obj.delete_deferred()
@csrf_protect_m
def delete_view(self, request, object_id, extra_context=None):
# the superclass version, but with the transaction.atomic context manager commented out (we do this ourselves)
# with transaction.atomic(using=router.db_for_write(self.model)):
return self._delete_view(request, object_id, extra_context)
# the preferred way to deal with ProjectMembership is actually through the inline above; however, because this may prove
# to not scale well with (very? more than 50?) memberships per project, we've left the separate admin interface here for

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.21 on 2025-07-03 13:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("projects", "0011_fill_stored_event_count"),
]
operations = [
migrations.AddField(
model_name="project",
name="is_deleted",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,48 @@
from django.db import migrations
def delete_objects_pointing_to_null_project(apps, schema_editor):
# Up until now, we have various models w/ .project=FK(null=True, on_delete=models.SET_NULL)
# Although it is "not expected" in the interface, project-deletion would have led to those
# objects with a null project. We're about to change that to .project=FK(null=False, ...) which
# would crash if we don't remove those objects first. Object-removal is "fine" though, because
# as per the meaning of the SET_NULL, these objects were "dangling" anyway.
# We implement this as a _single_ cross-app migration so that reasoning about the order of deletions is easy (and
# we can just copy the correct order from the project/tasks.py `preferred` variable. This cross-appness does mean
# that we must specify all dependencies here, and all the set-null migrations (from various apps) must point at this
# migration as their dependency.
# from tasks.py, but in "strings" form
preferred = [
'tags.EventTag',
'tags.IssueTag',
'tags.TagValue',
'tags.TagKey',
# 'issues.TurningPoint', # not needed, .project is already not-null (we just added it)
'events.Event',
'issues.Grouping',
# 'alerts.MessagingServiceConfig', was CASCADE (not null), so no deletion needed
# 'projects.ProjectMembership', was CASCADE (not null), so no deletion needed
'releases.Release',
'issues.Issue',
]
for model_name in preferred:
model = apps.get_model(*model_name.split('.'))
model.objects.filter(project__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
("projects", "0012_project_is_deleted"),
("issues", "0024_turningpoint_project_alter_not_null"),
("tags", "0004_alter_do_nothing"),
("releases", "0002_release_releases_re_sort_ep_5c07c8_idx"),
("events", "0021_alter_do_nothing"),
]
operations = [
migrations.RunPython(delete_objects_pointing_to_null_project, reverse_code=migrations.RunPython.noop),
]

View File

@@ -0,0 +1,19 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("projects", "0013_delete_objects_pointing_to_null_project"),
]
operations = [
migrations.AlterField(
model_name="projectmembership",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project"
),
),
]

View File

@@ -5,11 +5,14 @@ from django.conf import settings
from django.utils.text import slugify
from bugsink.app_settings import get_settings
from bugsink.transaction import delay_on_commit
from compat.dsn import build_dsn
from teams.models import TeamMembership
from .tasks import delete_project_deps
# ## Visibility/Access-design
#
@@ -74,6 +77,7 @@ class Project(models.Model):
name = models.CharField(max_length=255, blank=False, null=False, unique=True)
slug = models.SlugField(max_length=50, blank=False, null=False, unique=True)
is_deleted = models.BooleanField(default=False)
# sentry_key mirrors the "public" part of the sentry DSN. As of late 2023 Sentry's docs say the this about DSNs:
#
@@ -143,6 +147,13 @@ class Project(models.Model):
super().save(*args, **kwargs)
def delete_deferred(self):
"""Marks the project as deleted, and schedules deletion of all related objects"""
self.is_deleted = True
self.save(update_fields=["is_deleted"])
delay_on_commit(delete_project_deps, str(self.id))
def is_joinable(self, user=None):
if user is not None:
# take the user's team membership into account
@@ -164,7 +175,7 @@ class Project(models.Model):
class ProjectMembership(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE)
project = models.ForeignKey(Project, on_delete=models.DO_NOTHING)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
send_email_alerts = models.BooleanField(default=None, null=True)

View File

@@ -4,12 +4,13 @@ from snappea.decorators import shared_task
from bugsink.app_settings import get_settings
from bugsink.utils import send_rendered_email
from .models import Project
from bugsink.transaction import immediate_atomic, delay_on_commit
from bugsink.utils import get_model_topography, delete_deps_with_budget
@shared_task
def send_project_invite_email_new_user(email, project_pk, token):
from .models import Project # avoid circular import
project = Project.objects.get(pk=project_pk)
send_rendered_email(
@@ -30,6 +31,7 @@ def send_project_invite_email_new_user(email, project_pk, token):
@shared_task
def send_project_invite_email(email, project_pk):
from .models import Project # avoid circular import
project = Project.objects.get(pk=project_pk)
send_rendered_email(
@@ -45,3 +47,95 @@ def send_project_invite_email(email, project_pk):
}),
},
)
def get_model_topography_with_project_override():
"""
Returns the model topography with ordering adjusted to prefer deletions via .project, when available.
This assumes that Project is not only the root of the dependency graph, but also that if a model has an .project
ForeignKey, deleting it via that path is sufficient, meaning we can safely avoid visiting the same model again
through other ForeignKey routes (e.g. any of the .issue paths).
The preference is encoded via an explicit list of models, which are visited early and only via their .project path.
"""
from issues.models import Issue, TurningPoint, Grouping
from events.models import Event
from tags.models import IssueTag, EventTag, TagValue, TagKey
from alerts.models import MessagingServiceConfig
from releases.models import Release
from projects.models import ProjectMembership
preferred = [
# Tag-related: remove the "depending" models first and the most depended on last.
EventTag, # above Event, to avoid deletions via .event
IssueTag,
TagValue,
TagKey,
TurningPoint, # above Event, to avoid deletions via .triggering_event
Event, # above Grouping, to avoid deletions via .grouping
Grouping,
# these things "could be anywhere" in the ordering; they're not that connected; we put them at the end.
MessagingServiceConfig,
ProjectMembership,
Release,
Issue, # at the bottom, most everything points to this, we'd rather delete those things via .project
]
def as_preferred(lst):
"""
Sorts the list of (model, fk_name) tuples such that the models are in the preferred order as indicated above,
and models which occur with another fk_name are pruned
"""
return sorted(
[(model, fk_name) for model, fk_name in lst if fk_name == "project" or model not in preferred],
key=lambda x: preferred.index(x[0]) if x[0] in preferred else len(preferred),
)
topo = get_model_topography()
for k, lst in topo.items():
topo[k] = as_preferred(lst)
return topo
@shared_task
def delete_project_deps(project_id):
from .models import Project # avoid circular import
with immediate_atomic():
# matches what we do in events/retention.py (and for which argumentation exists); in practive I have seen _much_
# faster deletion times (in the order of .03s per task on my local laptop) when using a budget of 500, _but_
# it's not a given those were for "expensive objects" (e.g. events); and I'd rather err on the side of caution
# (worst case we have a bit of inefficiency; in any case this avoids hogging the global write lock / timeouts).
budget = 500
num_deleted = 0
dep_graph = get_model_topography_with_project_override()
for model_for_recursion, fk_name_for_recursion in dep_graph["projects.Project"]:
this_num_deleted = delete_deps_with_budget(
project_id,
model_for_recursion,
fk_name_for_recursion,
[project_id],
budget - num_deleted,
dep_graph,
is_for_project=True,
)
num_deleted += this_num_deleted
if num_deleted >= budget:
delay_on_commit(delete_project_deps, project_id)
return
if budget - num_deleted <= 0:
# no more budget for the self-delete.
delay_on_commit(delete_project_deps, project_id)
else:
# final step: delete the issue itself
Project.objects.filter(pk=project_id).delete()

View File

@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Alerts · {{ project.name }} · {{ site_title }}{% endblock %}
{% block content %}
<div class="flex items-center justify-center">
<div class="m-4 max-w-4xl flex-auto">
{% if messages %}
<ul class="mb-4">
{% for message in messages %}
{# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
<li class="bg-cyan-50 dark:bg-cyan-900 border-2 border-cyan-800 dark:border-cyan-400 p-4 rounded-lg">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="flex">
<h1 class="text-4xl mt-4 font-bold">{{ project.name }} · Alerts</h1>
<div class="ml-auto mt-6">
<a class="font-bold text-slate-800 dark:text-slate-100 border-slate-500 dark:border-slate-400 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 dark:bg-cyan-700 hover:bg-cyan-400 dark:hover:bg-cyan-600 active:ring rounded-md" href="{% url "project_messaging_service_add" project_pk=project.pk %}">Add</a>
</div>
</div>
<div>
<form action="." method="post">
{% csrf_token %}
<table class="w-full mt-8">
<tbody>
<thead>
<tr class="bg-slate-200 dark:bg-slate-800">
<th class="w-full p-4 text-left text-xl" colspan="2">Messaging Services</th>
</tr>
{% for service_config in service_configs %}
<tr class="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 border-b-2">
<td class="w-full p-4">
<div>
<a href="{% url "project_messaging_service_edit" project_pk=project.pk service_pk=service_config.pk %}" class="text-xl text-cyan-500 dark:text-cyan-300 font-bold">{{ service_config.display_name }}</a>
</div>
</td>
<td class="p-4">
<div class="flex justify-end">
<button name="action" value="test:{{ service_config.id }}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-md">Test</button>
<button name="action" value="remove:{{ service_config.id }}" class="font-bold text-slate-500 dark:text-slate-300 border-slate-300 dark:border-slate-600 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 dark:hover:bg-slate-800 active:ring rounded-md">Remove</button>
</div>
</td>
</tr>
{% empty %}
<tr class="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 border-b-2">
<td class="w-full p-4">
<div>
No Messaging Services Configured. <a href="{% url "project_messaging_service_add" project_pk=project.pk %}" class="text-cyan-500 dark:text-cyan-300 font-bold">Add Messaging Service</a>.
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
<div class="flex flex-direction-row">
<div class="ml-auto py-8 pr-4">
<a href="{% url "project_edit" project_pk=project.pk %}" class="text-cyan-500 dark:text-cyan-300 font-bold">Settings</a>
<span class="font-bold text-slate-500 dark:text-slate-300">|</span> <a href="{% url "project_list" %}" class="text-cyan-500 dark:text-cyan-300 font-bold">Back to Projects</a>
</div>
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More