Skip to content

Commit

Permalink
Merge pull request #3 from rasulkireev/status-page-upgrades
Browse files Browse the repository at this point in the history
Response Time Graph + Updates
  • Loading branch information
rasulkireev authored Oct 23, 2024
2 parents cc7ebaf + fe12e57 commit 07241f8
Show file tree
Hide file tree
Showing 14 changed files with 876 additions and 162 deletions.
13 changes: 10 additions & 3 deletions core/forms.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from allauth.account.forms import LoginForm, SignupForm
from django import forms

from core.models import Profile, Service
from core.models import Profile, Project, Service
from core.utils import DivErrorList


Expand Down Expand Up @@ -48,7 +48,14 @@ def save(self, commit=True):
class ServiceForm(forms.ModelForm):
class Meta:
model = Service
fields = ['name', 'type', 'url', 'check_interval', 'additional_data', 'is_public', 'is_active']
fields = ["name", "type", "url", "check_interval", "additional_data", "is_public", "is_active"]
widgets = {
'additional_data': forms.Textarea(attrs={'rows': 4}),
"additional_data": forms.Textarea(attrs={"rows": 4}),
}


class ProjectUpdateForm(forms.ModelForm):
class Meta:
model = Project
fields = ["name", "slug", "url", "icon", "public"]
widgets = {"icon": forms.FileInput(attrs={"class": "block mt-1 w-full text-sm text-gray-900"})}
18 changes: 18 additions & 0 deletions core/migrations/0006_project_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-10-23 06:07

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0005_alter_profilestatetransition_profile'),
]

operations = [
migrations.AddField(
model_name='project',
name='url',
field=models.URLField(blank=True),
),
]
43 changes: 18 additions & 25 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ class Profile(BaseModel):
user = models.OneToOneField(User, on_delete=models.CASCADE)
key = models.CharField(max_length=10, unique=True, default=generate_random_key)


subscription = models.ForeignKey(
"djstripe.Subscription",
null=True,
Expand Down Expand Up @@ -51,7 +50,6 @@ def current_state(self):
return latest_transition.to_state



class ProfileStates(models.TextChoices):
STRANGER = "stranger"
SIGNED_UP = "signed_up"
Expand All @@ -60,8 +58,11 @@ class ProfileStates(models.TextChoices):
CHURNED = "churned"
ACCOUNT_DELETED = "account_deleted"


class ProfileStateTransition(BaseModel):
profile = models.ForeignKey(Profile, null=True, blank=True, on_delete=models.SET_NULL, related_name="state_transitions")
profile = models.ForeignKey(
Profile, null=True, blank=True, on_delete=models.SET_NULL, related_name="state_transitions"
)
from_state = models.CharField(max_length=255, choices=ProfileStates.choices)
to_state = models.CharField(max_length=255, choices=ProfileStates.choices)
backup_profile_id = models.IntegerField()
Expand All @@ -84,11 +85,11 @@ def get_absolute_url(self):
return reverse("blog_post", kwargs={"slug": self.slug})



class Project(BaseModel):
profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="projects")
name = models.CharField(max_length=250, unique=True)
slug = models.SlugField(max_length=250, unique=True)
url = models.URLField(blank=True)
public = models.BooleanField(default=False)
icon = models.ImageField(upload_to="project_icons/", blank=True)

Expand All @@ -99,54 +100,46 @@ def get_absolute_url(self):
return reverse("project-status-page", kwargs={"slug": self.slug})



class Service(BaseModel):
class ServiceType(models.TextChoices):
WEBSITE = 'WEBSITE', 'Website'
API = 'API', 'API'
WEBSITE = "WEBSITE", "Website"
API = "API", "API"

project = models.ForeignKey('Project', on_delete=models.CASCADE, related_name="services")
project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="services")
name = models.CharField(max_length=250)
type = models.CharField(
max_length=20,
choices=ServiceType.choices,
default=ServiceType.WEBSITE
)
type = models.CharField(max_length=20, choices=ServiceType.choices, default=ServiceType.WEBSITE)
url = models.URLField(max_length=500, blank=True)
check_interval = models.PositiveIntegerField(default=5, help_text="Check interval in minutes")
additional_data = models.JSONField(
blank=True,
null=True,
help_text="Additional data for service checks (e.g., auth headers, connection strings)"
blank=True, null=True, help_text="Additional data for service checks (e.g., auth headers, connection strings)"
)
is_public = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)

class Meta:
unique_together = ['project', 'name']
unique_together = ["project", "name"]

def __str__(self):
return f"{self.name} ({self.get_type_display()})"



class ServiceStatus(BaseModel):
class StatusChoices(models.TextChoices):
UP = 'UP', 'Up'
DOWN = 'DOWN', 'Down'
DEGRADED = 'DEGRADED', 'Degraded'
UNKNOWN = 'UNKNOWN', 'Unknown'
UP = "UP", "Up"
DOWN = "DOWN", "Down"
DEGRADED = "DEGRADED", "Degraded"
UNKNOWN = "UNKNOWN", "Unknown"

service = models.ForeignKey('Service', on_delete=models.CASCADE, related_name='statuses')
service = models.ForeignKey("Service", on_delete=models.CASCADE, related_name="statuses")
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.UNKNOWN)
response_time = models.FloatField(null=True, blank=True, help_text="Response time in milliseconds")
status_code = models.IntegerField(null=True, blank=True)
error_message = models.TextField(blank=True)
checked_at = models.DateTimeField(default=timezone.now)

class Meta:
ordering = ['-checked_at']
get_latest_by = 'checked_at'
ordering = ["-checked_at"]
get_latest_by = "checked_at"

def __str__(self):
return f"{self.service.name} - {self.status} at {self.checked_at}"
Expand Down
58 changes: 36 additions & 22 deletions core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import redirect
from django.urls import reverse, reverse_lazy
from django.views.generic import CreateView, DetailView, FormView, ListView, TemplateView, UpdateView
from django.views.generic import CreateView, DetailView, ListView, TemplateView, UpdateView
from djstripe import models as djstripe_models

from core.forms import ProfileUpdateForm, ServiceForm
from core.forms import ProfileUpdateForm, ProjectUpdateForm, ServiceForm
from core.models import BlogPost, Profile, Project
from core.utils import check_if_profile_has_pro_subscription
from core.views_utils import StatusSummaryMixin
Expand Down Expand Up @@ -177,7 +177,7 @@ class BlogPostView(DetailView):
class CreateProjectView(LoginRequiredMixin, CreateView):
model = Project
template_name = "projects/create_project.html"
fields = ["name", "slug", "icon", "public"]
fields = ["name", "slug", "icon", "public", "url"]
success_url = reverse_lazy("home")

def form_valid(self, form):
Expand All @@ -196,39 +196,53 @@ def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
services = self.object.services.all()

# Add status summary to services (24 hours, 24 sticks)
self.add_status_summary_to_services(services, days=1, number_of_sticks=45)

# Get overall project status (90 days, 90 sticks)
context["project_overall_status"] = self.get_overall_project_status(services, days=90, number_of_sticks=90)

for service in services:
service.response_time_data = self.get_service_response_time_data(service)

# Get active and recently resolved incidents
active_incidents, recently_resolved = self.get_incidents(services)

context["services"] = services
context["active_incidents"] = active_incidents
context["recently_resolved"] = recently_resolved

return context


class ProjectSettingsView(StatusSummaryMixin, LoginRequiredMixin, FormView):
class ProjectSettingsView(StatusSummaryMixin, LoginRequiredMixin, UpdateView):
model = Project
form_class = ProjectUpdateForm
template_name = "projects/project_settings.html"
form_class = ServiceForm

def dispatch(self, request, *args, **kwargs):
self.project = get_object_or_404(Project, slug=self.kwargs["slug"])
return super().dispatch(request, *args, **kwargs)
slug_url_kwarg = "slug"

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["project"] = self.project
services = self.project.services.all()

context["service_form"] = ServiceForm()
services = self.object.services.all()
self.add_status_summary_to_services(services)
context["services"] = services
return context

def form_valid(self, form):
service = form.save(commit=False)
service.project = self.project
service.save()
messages.success(self.request, f"Service '{service.name}' has been successfully created!")
return super().form_valid(form)
response = super().form_valid(form)
messages.success(self.request, f"Project '{self.object.name}' has been successfully updated!")
return response

def get_success_url(self):
return reverse("project-settings", kwargs={"slug": self.project.slug})
return reverse("project-settings", kwargs={"slug": self.object.slug})

def post(self, request, *args, **kwargs):
self.object = self.get_object()
if "service_form" in request.POST:
service_form = ServiceForm(request.POST)
if service_form.is_valid():
service = service_form.save(commit=False)
service.project = self.object
service.save()
messages.success(request, f"Service '{service.name}' has been successfully created!")
return redirect(self.get_success_url())
else:
return super().post(request, *args, **kwargs)
44 changes: 44 additions & 0 deletions core/views_utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,53 @@
import json
from datetime import timedelta

from django.db.models import Avg, OuterRef, Subquery
from django.utils import timezone

from core.models import ServiceStatus


class StatusSummaryMixin:
def get_incidents(self, services):
twenty_four_hours_ago = timezone.now() - timedelta(hours=24)

down_statuses = ServiceStatus.objects.filter(
service__in=services, status=ServiceStatus.StatusChoices.DOWN, checked_at__gte=twenty_four_hours_ago
)

up_after_down = ServiceStatus.objects.filter(
service=OuterRef("service"), status=ServiceStatus.StatusChoices.UP, checked_at__gt=OuterRef("checked_at")
).order_by("checked_at")

down_statuses_annotated = down_statuses.annotate(
latest_up_after=Subquery(up_after_down.values("checked_at")[:1])
)

active_incidents = down_statuses_annotated.filter(latest_up_after__isnull=True)

recently_resolved = down_statuses_annotated.filter(latest_up_after__isnull=False)

return active_incidents, recently_resolved

def get_service_response_time_data(self, service):
end_time = timezone.now()
start_time = end_time - timedelta(hours=24)

hourly_data = (
service.statuses.filter(checked_at__gte=start_time)
.extra({"hour": "date_trunc('hour', checked_at)"})
.values("hour")
.annotate(response_time=Avg("response_time"))
.order_by("hour")
)

return json.dumps(
[
{"timestamp": entry["hour"].isoformat(), "response_time": float(entry["response_time"] or 0)}
for entry in hourly_data
]
)

@staticmethod
def get_status_summary(statuses, end_time, start_time, number_of_sticks):
summary = []
Expand Down
Loading

0 comments on commit 07241f8

Please sign in to comment.