Skip to content

Commit

Permalink
Merge pull request #4 from rasulkireev/improvements
Browse files Browse the repository at this point in the history
Add API checks
  • Loading branch information
rasulkireev authored Oct 25, 2024
2 parents 499b09f + 0947235 commit 6d17762
Show file tree
Hide file tree
Showing 12 changed files with 292 additions and 41 deletions.
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ test-webhook:
stripe-sync:
docker compose run --rm backend python ./manage.py djstripe_sync_models Product Price

restart-worker:
docker compose up -d workers --force-recreate
26 changes: 24 additions & 2 deletions core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,33 @@ 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",
"is_public",
"is_active",
"http_method",
"request_headers",
"request_body",
"expected_status_code",
"expected_response_content",
]
widgets = {
"additional_data": forms.Textarea(attrs={"rows": 4}),
"request_headers": forms.Textarea(attrs={"rows": 3}),
"request_body": forms.Textarea(attrs={"rows": 3}),
"expected_response_content": forms.Textarea(attrs={"rows": 3}),
}

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["http_method"].required = False
self.fields["request_headers"].required = False
self.fields["request_body"].required = False
self.fields["expected_status_code"].required = True
self.fields["expected_response_content"].required = False


class ProjectUpdateForm(forms.ModelForm):
class Meta:
Expand Down
38 changes: 38 additions & 0 deletions core/migrations/0007_service_expected_response_content_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 5.0.4 on 2024-10-25 06:31

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0006_project_url'),
]

operations = [
migrations.AddField(
model_name='service',
name='expected_response_content',
field=models.TextField(blank=True, help_text='Expected content in the API response', null=True),
),
migrations.AddField(
model_name='service',
name='expected_status_code',
field=models.PositiveIntegerField(blank=True, help_text='Expected HTTP status code', null=True),
),
migrations.AddField(
model_name='service',
name='http_method',
field=models.CharField(choices=[('GET', 'GET'), ('POST', 'POST'), ('PUT', 'PUT'), ('PATCH', 'PATCH'), ('DELETE', 'DELETE')], default='GET', max_length=10),
),
migrations.AddField(
model_name='service',
name='request_body',
field=models.TextField(blank=True, help_text='Body of the API request', null=True),
),
migrations.AddField(
model_name='service',
name='request_headers',
field=models.JSONField(blank=True, help_text='Headers to be sent with the API request', null=True),
),
]
17 changes: 17 additions & 0 deletions core/migrations/0008_remove_service_additional_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.0.4 on 2024-10-25 06:32

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('core', '0007_service_expected_response_content_and_more'),
]

operations = [
migrations.RemoveField(
model_name='service',
name='additional_data',
),
]
18 changes: 18 additions & 0 deletions core/migrations/0009_alter_servicestatus_error_message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-10-25 07:02

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0008_remove_service_additional_data'),
]

operations = [
migrations.AlterField(
model_name='servicestatus',
name='error_message',
field=models.TextField(blank=True, null=True),
),
]
42 changes: 39 additions & 3 deletions core/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.db import models
from django.urls import reverse
from django.utils import timezone
Expand Down Expand Up @@ -105,14 +107,28 @@ class ServiceType(models.TextChoices):
WEBSITE = "WEBSITE", "Website"
API = "API", "API"

class HttpMethod(models.TextChoices):
GET = "GET", "GET"
POST = "POST", "POST"
PUT = "PUT", "PUT"
PATCH = "PATCH", "PATCH"
DELETE = "DELETE", "DELETE"

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)
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)"

# API-specific fields
http_method = models.CharField(max_length=10, choices=HttpMethod.choices, default=HttpMethod.GET)
request_headers = models.JSONField(blank=True, null=True, help_text="Headers to be sent with the API request")
request_body = models.TextField(blank=True, null=True, help_text="Body of the API request")
expected_status_code = models.PositiveIntegerField(blank=True, null=True, help_text="Expected HTTP status code")
expected_response_content = models.TextField(
blank=True, null=True, help_text="Expected content in the API response"
)

is_public = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)

Expand All @@ -122,6 +138,26 @@ class Meta:
def __str__(self):
return f"{self.name} ({self.get_type_display()})"

def clean(self):
super().clean()
if self.type == self.ServiceType.API:
if not self.url:
raise ValidationError("URL is required for API services")
URLValidator()(self.url)

def get_check_params(self):
if self.type == self.ServiceType.API:
return {
"url": self.url,
"method": self.http_method,
"headers": self.request_headers or {},
"body": self.request_body,
"expected_status_code": self.expected_status_code,
"expected_response_content": self.expected_response_content,
}
# Add logic for other service types if needed
return {}


class ServiceStatus(BaseModel):
class StatusChoices(models.TextChoices):
Expand All @@ -134,7 +170,7 @@ class StatusChoices(models.TextChoices):
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)
error_message = models.TextField(blank=True, null=True)
checked_at = models.DateTimeField(default=timezone.now)

class Meta:
Expand Down
98 changes: 75 additions & 23 deletions core/tasks.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import requests
from django.conf import settings
from django.db.models import DurationField, ExpressionWrapper, F, Max, Q
from django.utils import timezone
from django.db.models import Q, Max, F, ExpressionWrapper, DurationField
from django_q.tasks import async_task

from core.models import Service, ServiceStatus
from statushen.utils import get_statushen_logger

logger = get_statushen_logger(__name__)


def add_email_to_buttondown(email, tag):
data = {
"email_address": str(email),
Expand All @@ -34,15 +35,15 @@ def schedule_service_checks():
"""
now = timezone.now()

services_to_check = Service.objects.filter(is_active=True).annotate(
last_checked=Max('statuses__checked_at'),
check_interval_duration=ExpressionWrapper(
F('check_interval') * timezone.timedelta(minutes=1),
output_field=DurationField()
services_to_check = (
Service.objects.filter(is_active=True)
.annotate(
last_checked=Max("statuses__checked_at"),
check_interval_duration=ExpressionWrapper(
F("check_interval") * timezone.timedelta(minutes=1), output_field=DurationField()
),
)
).filter(
Q(last_checked__isnull=True) |
Q(last_checked__lte=now - F('check_interval_duration'))
.filter(Q(last_checked__isnull=True) | Q(last_checked__lte=now - F("check_interval_duration")))
)

count = 0
Expand All @@ -62,26 +63,77 @@ def check_service(service_id):
except Service.DoesNotExist:
return

try:
response = requests.get(service.url, timeout=10)
status = "UNKNOWN"

if response.ok:
status = ServiceStatus.StatusChoices.UP
logger.info("Initiating check", service_id=service.id, service_type=service.type)

try:
if service.type == Service.ServiceType.API:
status, response_time, status_code, error_message = check_api_service(service)
else:
status = ServiceStatus.StatusChoices.DOWN
status, response_time, status_code, error_message = check_website_service(service)

ServiceStatus.objects.create(
service=service,
status=status,
response_time=response.elapsed.total_seconds() * 1000,
status_code=response.status_code
)
except requests.RequestException as e:
# Handle network errors, timeouts, etc.
ServiceStatus.objects.create(
service=service,
status=ServiceStatus.StatusChoices.DOWN,
error_message=str(e)
response_time=response_time,
status_code=status_code,
error_message=error_message,
)
except Exception as e:
logger.error("[Check Service] Failed", service_id=service.id, error=str(e))
ServiceStatus.objects.create(service=service, status=ServiceStatus.StatusChoices.DOWN, error_message=str(e))

return f"Check complete. Status: {status}"


def check_website_service(service):
"""
Check a website service.
"""
response = requests.get(service.url, timeout=10)

if response.ok:
status = ServiceStatus.StatusChoices.UP
else:
status = ServiceStatus.StatusChoices.DOWN

return status, response.elapsed.total_seconds() * 1000, response.status_code, None


def check_api_service(service):
"""
Check an API service.
"""
method = service.http_method or "GET"
headers = service.request_headers
data = service.request_body

try:
response = requests.request(method, service.url, headers=headers, data=data, timeout=10)

status = ServiceStatus.StatusChoices.UP
error_message = None

# Check expected status code
if service.expected_status_code and response.status_code != service.expected_status_code:
status = ServiceStatus.StatusChoices.DOWN
error_message = f"Unexpected status code: {response.status_code}"

# Check expected response content
if service.expected_response_content and service.expected_response_content not in response.text:
status = ServiceStatus.StatusChoices.DOWN
error_message = "Expected content not found in response"

logger.info(
"API Status Check Successfull",
status=status,
time=response.elapsed.total_seconds() * 1000,
status_code=response.status_code,
error_message=error_message,
)

return status, response.elapsed.total_seconds() * 1000, response.status_code, error_message

except requests.RequestException as e:
return ServiceStatus.StatusChoices.DOWN, None, None, str(e)
3 changes: 3 additions & 0 deletions core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ def get_context_data(self, **kwargs):

self.add_status_summary_to_services(services, days=1, number_of_sticks=45)
context["project_overall_status"] = self.get_overall_project_status(services, days=90, number_of_sticks=90)
context["project_overall_status_mobile"] = self.get_overall_project_status(
services, days=90, number_of_sticks=40
)

for service in services:
service.response_time_data = self.get_service_response_time_data(service)
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/controllers/service_form_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// app/javascript/controllers/service_form_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = ["apiField"];

connect() {
this.toggleApiFields();
}

toggleApiFields() {
const serviceType = this.element.querySelector('[name="type"]').value;
const isApiService = serviceType === "API";

this.apiFieldTargets.forEach(field => {
if (isApiService) {
field.style.display = "block";
} else {
field.style.display = "none";
}
});
}
}
2 changes: 1 addition & 1 deletion frontend/templates/projects/create_project.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ <h1 class="mb-8 text-3xl font-bold tracking-tight text-center text-gray-900 sm:t

<div>
{{ form.slug.errors | safe }}
<label for="{{ form.slug.id_for_label }}" class="block text-sm font-medium text-gray-700">Project URL</label>
<label for="{{ form.slug.id_for_label }}" class="block text-sm font-medium text-gray-700">Project Slug</label>
<div class="flex mt-1 rounded-md shadow-sm">
<span class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-50 rounded-l-md border border-r-0 border-gray-300">
statushen.com/
Expand Down
Loading

0 comments on commit 6d17762

Please sign in to comment.