mirror of
https://github.com/jlengrand/bugsink.git
synced 2026-03-10 08:01:17 +00:00
Merge branch 'main' into django-5-2
This commit is contained in:
125
CHANGELOG.md
125
CHANGELOG.md
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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
21
alerts/forms.py
Normal 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
|
||||
52
alerts/migrations/0001_initial.py
Normal file
52
alerts/migrations/0001_initial.py
Normal 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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
0
alerts/migrations/__init__.py
Normal file
0
alerts/migrations/__init__.py
Normal 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)
|
||||
|
||||
0
alerts/service_backends/__init__.py
Normal file
0
alerts/service_backends/__init__.py
Normal file
164
alerts/service_backends/slack.py
Normal file
164
alerts/service_backends/slack.py
Normal 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("&", "&").replace("<", "<").replace(">", ">").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)
|
||||
@@ -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})',
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
42
bugsink/scripts/util.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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 Django’s 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")
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
150
bugsink/utils.py
150
bugsink/utils.py
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 }")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
27
events/migrations/0021_alter_do_nothing.py
Normal file
27
events/migrations/0021_alter_do_nothing.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
21
events/migrations/0022_alter_event_project.py
Normal file
21
events/migrations/0022_alter_event_project.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
16
events/migrations/0023_event_remote_addr.py
Normal file
16
events/migrations/0023_event_remote_addr.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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))
|
||||
|
||||
@@ -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
53
events/tasks.py
Normal 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"])
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
|
||||
0
files/management/__init__.py
Normal file
0
files/management/__init__.py
Normal file
0
files/management/commands/__init__.py
Normal file
0
files/management/commands/__init__.py
Normal file
10
files/management/commands/vacuum_files.py
Normal file
10
files/management/commands/vacuum_files.py
Normal 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).")
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
105
files/tasks.py
105
files/tasks.py
@@ -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)
|
||||
|
||||
102
files/tests.py
102
files/tests.py
@@ -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))
|
||||
|
||||
@@ -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
4
gunicorn.docker.conf.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# gunicorn config file for Docker deployments
|
||||
import multiprocessing
|
||||
|
||||
workers = min(multiprocessing.cpu_count(), 4)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
17
issues/migrations/0014_grouping_grouping_key_hash.py
Normal file
17
issues/migrations/0014_grouping_grouping_key_hash.py
Normal 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,
|
||||
),
|
||||
]
|
||||
20
issues/migrations/0015_set_grouping_hash.py
Normal file
20
issues/migrations/0015_set_grouping_hash.py
Normal 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),
|
||||
]
|
||||
16
issues/migrations/0016_alter_grouping_unique_together.py
Normal file
16
issues/migrations/0016_alter_grouping_unique_together.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
16
issues/migrations/0018_issue_is_deleted.py
Normal file
16
issues/migrations/0018_issue_is_deleted.py
Normal 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),
|
||||
),
|
||||
]
|
||||
16
issues/migrations/0019_alter_grouping_grouping_key_hash.py
Normal file
16
issues/migrations/0019_alter_grouping_grouping_key_hash.py
Normal 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),
|
||||
),
|
||||
]
|
||||
26
issues/migrations/0020_remove_objects_with_null_issue.py
Normal file
26
issues/migrations/0020_remove_objects_with_null_issue.py
Normal 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),
|
||||
]
|
||||
26
issues/migrations/0021_alter_do_nothing.py
Normal file
26
issues/migrations/0021_alter_do_nothing.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
22
issues/migrations/0022_turningpoint_project.py
Normal file
22
issues/migrations/0022_turningpoint_project.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
36
issues/migrations/0023_turningpoint_set_project.py
Normal file
36
issues/migrations/0023_turningpoint_set_project.py
Normal 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),
|
||||
]
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
82
issues/tasks.py
Normal 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()
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <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 <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 <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 <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 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 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 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 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 <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 <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 <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 <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> {% 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
126
issues/tests.py
126
issues/tests.py
@@ -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'),
|
||||
])
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
18
projects/migrations/0012_project_is_deleted.py
Normal file
18
projects/migrations/0012_project_is_deleted.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
19
projects/migrations/0014_alter_projectmembership_project.py
Normal file
19
projects/migrations/0014_alter_projectmembership_project.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
82
projects/templates/projects/project_alerts_setup.html
Normal file
82
projects/templates/projects/project_alerts_setup.html
Normal 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
Reference in New Issue
Block a user