Add base dashboard view

This commit is contained in:
R. Miles McCain 2020-04-12 01:46:28 -04:00
parent 170a8ec00b
commit 565cba18e2
No known key found for this signature in database
GPG Key ID: 91CB47BDDF2671A5
34 changed files with 762 additions and 150 deletions

View File

@ -15,8 +15,7 @@ django-ipware = "*"
pyyaml = "*"
ua-parser = "*"
user-agents = "*"
django-humanize = "*"
anonymizeip = "*"
emoji-country-flag = "*"
[requires]
python_version = "3.6"

32
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "5c4a63923138fea970c8851ac72d6d6a391db28090a02d738e675775d2c6f26b"
"sha256": "93fe2edcab5c588173dcaa4799fd66873e47014f417920ad5a969544a33e5cb5"
},
"pipfile-spec": 6,
"requires": {
@ -23,14 +23,6 @@
],
"version": "==2.5.2"
},
"anonymizeip": {
"hashes": [
"sha256:491cb94a31bae23294c5b93a13dd5c9ed55be98003c622e76e2fe64d6a4f3e91",
"sha256:e0d446b06b2bbf236394a90b971de403f90f805d14db3a405f8731716acad1fe"
],
"index": "pypi",
"version": "==1.0.0"
},
"asgiref": {
"hashes": [
"sha256:8036f90603c54e93521e5777b2b9a39ba1bad05773fcf2d208f0299d1df58ce5",
@ -89,13 +81,6 @@
"index": "pypi",
"version": "==0.41.0"
},
"django-humanize": {
"hashes": [
"sha256:32491bf0209b89a277f7bfdab7fd6d4cc7944bb037f742d62e8e447a575c0028"
],
"index": "pypi",
"version": "==0.1.2"
},
"django-ipware": {
"hashes": [
"sha256:a7c7a8fd019dbdc9c357e6e582f65034e897572fc79a7e467674efa8aef9d00b"
@ -103,6 +88,14 @@
"index": "pypi",
"version": "==2.1.0"
},
"emoji-country-flag": {
"hashes": [
"sha256:67c0cb6a3765fb53f31b34160d6b1c8a5f44b297bc278d1835c6f2e5b0a9a592",
"sha256:ae7edb38077b0840210fa9e37673f481f2b9c032446e13ad6dab2b1108cd7ad6"
],
"index": "pypi",
"version": "==1.2.1"
},
"geoip2": {
"hashes": [
"sha256:5869e987bc54c0d707264fec4710661332cc38d2dca5a7f9bb5362d0308e2ce0",
@ -111,13 +104,6 @@
"index": "pypi",
"version": "==3.0.0"
},
"humanize": {
"hashes": [
"sha256:98b7ac9d1a70ad62175c8e0dd44beebbd92418727fc4e214468dfb2baa8ebfb5",
"sha256:bc2a1ff065977011de2bc36197a4b14730c54bfc46ab12a153376684573a2dab"
],
"version": "==2.3.0"
},
"idna": {
"hashes": [
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",

View File

@ -2,4 +2,4 @@ from django.apps import AppConfig
class A17TConfig(AppConfig):
name = 'a17t'
name = "a17t"

View File

@ -1,7 +1,6 @@
{% load a17t_tags %}
<field role="field" class="block mb-2">
<field role="field" class="block mb-4">
{% if field|is_checkbox %}
<label class="switch {{ classes.label }}">
{{ field }}
@ -12,25 +11,25 @@
<label class="label my-1" for="{{field.auto_id}}">{{ field.label }}</label>
{% endif %}
{% for choice in field %}
<label class="switch">
<label class="switch my-1">
{{ choice.tag }}
<span>{{ choice.choice_label }}</span>
</label>
{% endfor %}
{% elif field|is_input %}
<label class="label" for="{{field.auto_id}}">{{ field.label }}</label>
{{field|add_class:"input"}}
{% include 'a17t/includes/label.html' %}
{{field|add_class:"input my-1"}}
{% elif field|is_textarea %}
<label class="label" for="{{field.auto_id}}">{{ field.label }}</label>
{{ field|add_class:'textarea' }}
{% include 'a17t/includes/label.html' %}
{{ field|add_class:'textarea my-1' }}
{% elif field|is_select %}
<label class="label" for="{{field.auto_id}}">{{ field.label }}</label>
<div class="select {% if field.errors|length > 0 %}~critical{% endif %}">
{% include 'a17t/includes/label.html' %}
<div class="select {% if field.errors|length > 0 %}~critical{% endif %} my-1">
{{field}}
</div>
{% else %}
<label class="label" for="{{field.auto_id}}">{{ field.label }}</label>
{{field|add_class:"field"}}
{% include 'a17t/includes/label.html' %}
{{field|add_class:"field my-1"}}
{% endif %}
{% for error in field.errors %}

View File

@ -0,0 +1 @@
<label class="label" for="{{field.auto_id}}">{{ field.label }} {% if not field.field.required %}<span class="badge ~neutral">Optional</span>{% endif %}</label>

View File

@ -1,5 +1,4 @@
from django import forms
from django import template
from django import forms, template
from django.forms import BoundField
from django.template.loader import get_template
from django.utils.safestring import mark_safe

View File

@ -6,16 +6,10 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('analytics', '0002_auto_20200410_0258'),
("analytics", "0002_auto_20200410_0258"),
]
operations = [
migrations.RemoveField(
model_name='hit',
name='metadata_raw',
),
migrations.RemoveField(
model_name='session',
name='metadata_raw',
),
migrations.RemoveField(model_name="hit", name="metadata_raw",),
migrations.RemoveField(model_name="session", name="metadata_raw",),
]

View File

@ -1,34 +1,29 @@
# Generated by Django 3.0.5 on 2020-04-11 19:41
from django.db import migrations, models
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('analytics', '0003_auto_20200410_1325'),
("analytics", "0003_auto_20200410_1325"),
]
operations = [
migrations.RenameField(
model_name='hit',
old_name='start',
new_name='start_time',
model_name="hit", old_name="start", new_name="start_time",
),
migrations.RenameField(
model_name='session',
old_name='first_seen',
new_name='start_time',
),
migrations.RemoveField(
model_name='hit',
name='duration',
model_name="session", old_name="first_seen", new_name="start_time",
),
migrations.RemoveField(model_name="hit", name="duration",),
migrations.AddField(
model_name='hit',
name='last_seen',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
model_name="hit",
name="last_seen",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 3.0.5 on 2020-04-12 03:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("analytics", "0004_auto_20200411_1541"),
]
operations = [
migrations.AddField(
model_name="hit", name="initial", field=models.BooleanField(default=True),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.0.5 on 2020-04-12 04:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("analytics", "0005_hit_initial"),
]
operations = [
migrations.RemoveField(model_name="hit", name="httpStatus",),
migrations.AddField(
model_name="session",
name="device_type",
field=models.CharField(
choices=[
("PHONE", "Phone"),
("TABLET", "Tablet"),
("DESK", "Desktop / Laptop"),
("ROBOT", "Robot"),
("OTHER", "Other"),
],
default="OTHER",
max_length=6,
),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 3.0.5 on 2020-04-12 04:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("analytics", "0006_auto_20200412_0003"),
]
operations = [
migrations.AlterField(
model_name="session",
name="device_type",
field=models.CharField(
choices=[
("PHONE", "Phone"),
("TABLET", "Tablet"),
("DESKTOP", "Desktop"),
("ROBOT", "Robot"),
("OTHER", "Other"),
],
default="OTHER",
max_length=7,
),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 3.0.5 on 2020-04-12 04:15
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("analytics", "0007_auto_20200412_0010"),
]
operations = [
migrations.RenameField(
model_name="hit", old_name="loadTime", new_name="load_time",
),
]

View File

@ -2,6 +2,7 @@ import json
import uuid
from django.db import models
from django.utils import timezone
from core.models import Service
@ -25,6 +26,17 @@ class Session(models.Model):
user_agent = models.TextField()
browser = models.TextField()
device = models.TextField()
device_type = models.CharField(
max_length=7,
choices=[
("PHONE", "Phone"),
("TABLET", "Tablet"),
("DESKTOP", "Desktop"),
("ROBOT", "Robot"),
("OTHER", "Other"),
],
default="OTHER",
)
os = models.TextField()
ip = models.GenericIPAddressField()
@ -35,9 +47,18 @@ class Session(models.Model):
latitude = models.FloatField(null=True)
time_zone = models.TextField(blank=True)
@property
def is_currently_active(self):
return timezone.now() - self.last_seen < timezone.timedelta(seconds=10)
@property
def duration(self):
return self.last_seen - self.start_time
class Hit(models.Model):
session = models.ForeignKey(Session, on_delete=models.CASCADE)
initial = models.BooleanField(default=True)
# Base request information
start_time = models.DateTimeField(auto_now_add=True)
@ -48,6 +69,4 @@ class Hit(models.Model):
# Advanced page information
location = models.TextField(blank=True)
referrer = models.TextField(blank=True)
loadTime = models.FloatField(null=True)
httpStatus = models.IntegerField(null=True)
load_time = models.FloatField(null=True)

View File

@ -7,7 +7,6 @@ from celery import shared_task
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from anonymizeip import anonymize_ip
from core.models import Service
@ -51,13 +50,10 @@ def ingress_request(
ip_data = _geoip2_lookup(ip)
log.debug(f"Found geoip2 data")
if service.anonymize_ips:
ip = anonymize_ip(ip)
# Create or update session
session = Session.objects.filter(
service=service,
last_seen__gt=timezone.now() - timezone.timedelta(minutes=30),
last_seen__gt=timezone.now() - timezone.timedelta(minutes=10),
ip=ip,
user_agent=user_agent,
identifier=identifier,
@ -65,15 +61,25 @@ def ingress_request(
if session is None:
log.debug("Cannot link to existing session; creating a new one...")
ua = user_agents.parse(user_agent)
initial = True
device_type = "OTHER"
if ua.is_mobile:
device_type = "PHONE"
elif ua.is_tablet:
device_type = "TABLET"
elif ua.is_pc:
device_type = "DESKTOP"
elif ua.is_bot:
device_type = "ROBOT"
session = Session.objects.create(
service=service,
ip=ip,
user_agent=user_agent,
identifier=identifier,
browser=f"{ua.browser.family or ''} {ua.browser.version_string or ''}".strip(),
device=f"{ua.device.model or ''}",
os=f"{ua.os.family or ''} {ua.os.version_string or ''}".strip(),
browser=ua.browser.family or "",
device=ua.device.model or "",
device_type=device_type,
os=ua.os.family or "",
asn=ip_data.get("asn", ""),
country=ip_data.get("country", ""),
longitude=ip_data.get("longitude"),
@ -82,6 +88,7 @@ def ingress_request(
)
else:
log.debug("Updating old session with new data...")
initial = False
# Update last seen time
session.last_seen = timezone.now()
session.save()
@ -92,6 +99,7 @@ def ingress_request(
hit = None
if idempotency is not None:
if cache.get(idempotency_path) is not None:
cache.touch(idempotency_path, 10 * 60)
hit = Hit.objects.filter(
pk=cache.get(idempotency_path), session=session
).first()
@ -107,14 +115,15 @@ def ingress_request(
# There is no existing hit; create a new one
hit = Hit.objects.create(
session=session,
initial=initial,
tracker=tracker,
location=location,
referrer=payload.get("referrer", ""),
loadTime=payload.get("loadTime"),
load_time=payload.get("loadTime"),
)
# Set idempotency (if applicable)
if idempotency is not None:
cache.set(idempotency_path, hit.pk, timeout=30 * 60)
cache.set(idempotency_path, hit.pk, timeout=10 * 60)
except Exception as e:
log.exception(e)
raise e

24
shynet/core/forms.py Normal file
View File

@ -0,0 +1,24 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import Service
class ServiceForm(forms.ModelForm):
class Meta:
model = Service
fields = ["name", "link", "origins"]
widgets = {
"name": forms.TextInput(),
"origins": forms.TextInput(),
}
labels = {
"origins": "Allowed Hostnames",
}
help_texts = {
"name": _("What should the service be called?"),
"link": _("What's the service's primary URL?"),
"origins": _(
"At what hostnames does the service operate? This sets CORS headers, so use '*' if you're not sure (or don't care)."
),
}

View File

@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
("core", "0001_initial"),
]
operations = [
migrations.AddField(
model_name='service',
name='anonymize_ips',
model_name="service",
name="anonymize_ips",
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,14 @@
# Generated by Django 3.0.5 on 2020-04-11 23:25
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0002_service_anonymize_ips"),
]
operations = [
migrations.RemoveField(model_name="service", name="anonymize_ips",),
]

36
shynet/core/mixins.py Normal file
View File

@ -0,0 +1,36 @@
from urllib.parse import urlparse
from django.utils import timezone
class DateRangeMixin:
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
if self.request.GET.get("startDate") != None:
found_time = timezone.datetime.strptime(
self.request.GET.get("startDate"), "%Y-%m-%d"
)
found_time.replace(hour=0, minute=0)
data["start_date"] = found_time
else:
data["start_date"] = timezone.now() - timezone.timedelta(days=30)
if self.request.GET.get("endDate") != None:
found_time = timezone.datetime.strptime(
self.request.GET.get("endDate"), "%Y-%m-%d"
)
found_time.replace(hour=23, minute=59)
data["end_date"] = found_time
else:
data["end_date"] = timezone.now()
return data
class BaseUrlMixin:
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
url_data = urlparse(self.request.build_absolute_uri())
data["base_url"] = f"{url_data.scheme}://{url_data.netloc}"
return data

View File

@ -1,10 +1,10 @@
import uuid
from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.apps import apps
from django.utils import timezone
from django.db.utils import NotSupportedError
from django.utils import timezone
def _default_uuid():
@ -39,9 +39,6 @@ class Service(models.Model):
max_length=2, choices=SERVICE_STATUSES, default=ACTIVE, db_index=True
)
# Analytics settings
anonymize_ips = models.BooleanField(default=False)
def __str__(self):
return self.name
@ -63,9 +60,13 @@ class Service(models.Model):
service=self, start_time__gt=timezone.now() - timezone.timedelta(seconds=10)
).count()
sessions = Session.objects.filter(
sessions = (
Session.objects.filter(
service=self, start_time__gt=start_time, start_time__lt=end_time
)
.prefetch_related("hit_set")
.order_by("-start_time")
)
session_count = sessions.count()
hits = Hit.objects.filter(
@ -76,28 +77,80 @@ class Service(models.Model):
bounces = sessions.annotate(hit_count=models.Count("hit")).filter(hit_count=1)
bounce_count = bounces.count()
locations = (
hits.values("location")
.annotate(count=models.Count("location"))
.order_by("-count")
)
referrers = (
hits.filter(initial=True)
.values("referrer")
.annotate(count=models.Count("referrer"))
.order_by("-count")
)
operating_systems = (
sessions.values("os").annotate(count=models.Count("os")).order_by("-count")
)
browsers = (
sessions.values("browser")
.annotate(count=models.Count("browser"))
.order_by("-count")
)
device_types = (
sessions.values("device_type")
.annotate(count=models.Count("device_type"))
.order_by("-count")
)
devices = (
sessions.values("device")
.annotate(count=models.Count("device"))
.order_by("-count")
)
device_types = (
sessions.values("device_type")
.annotate(count=models.Count("device_type"))
.order_by("-count")
)
avg_load_time = hits.aggregate(load_time__avg=models.Avg("load_time"))[
"load_time__avg"
]
avg_hits_per_session = hit_count / max(session_count, 1)
try:
avg_session_duration = sessions.annotate(
duration=models.F("last_seen") - models.F("start_time")
).aggregate(duration=models.Avg("duration"))["duration"]
except NotSupportedError:
avg_session_duration = (
sum(
avg_session_duration = sum(
[
(session.last_seen - session.start_time).total_seconds()
for session in sessions
]
)
/ session_count
)
) / max(session_count, 1)
return {
"currently_online": currently_online,
"sessions": session_count,
"hits": hit_count,
"session_count": session_count,
"hit_count": hit_count,
"avg_hits_per_session": hit_count / (max(session_count, 1)),
"bounce_rate_pct": bounce_count * 100 / session_count,
"bounce_rate_pct": bounce_count * 100 / (max(session_count, 1)),
"avg_session_duration": avg_session_duration,
"uptime": 99.9,
"avg_load_time": avg_load_time,
"avg_hits_per_session": avg_hits_per_session,
"locations": locations,
"referrers": referrers,
"operating_systems": operating_systems,
"browsers": browsers,
"devices": devices,
"device_types": device_types,
"sessions": sessions,
"online": True,
}

View File

@ -7,6 +7,7 @@
<meta name="robots" content="noindex">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% include 'a17t/head.html' %}
<script src="https://cdn.jsdelivr.net/npm/litepicker/dist/js/main.js"></script>
{% block extra_head %}
{% endblock %}
</head>
@ -15,18 +16,20 @@
{% block body %}
<section class="max-w-screen-lg mx-auto px-6 md:py-12 md:flex">
<aside class="w-2/12 mr-4 block">
<a class="icon ~urge ml-6 mb-8 mt-3" href="{% url 'core:dashboard' %}">
<aside class="w-2/12 mr-8 block">
<a class="icon ~urge ml-6 mb-12 mt-3" href="{% url 'core:dashboard' %}">
<i class="fas fa-low-vision fa-3x text-purple-600"></i>
</a>
<p class="ml-2 mt-2 mb-1 supra font-medium text-gray-500 pointer-events-none">Services</p>
{% for service in user.owning_services.all %}
{% url 'account_email' as url %}
{% url 'core:service' service.uuid as url %}
{% include 'core/includes/sidebar_portal.html' with label=service.name url=url %}
{% endfor %}
{% url 'core:service_create' as url %}
{% include 'core/includes/sidebar_portal.html' with label="+ Create" url=url %}
<p class="ml-2 mt-8 mb-1 supra font-medium text-gray-500 pointer-events-none">Account</p>
{% if user.is_authenticated %}

View File

@ -0,0 +1,33 @@
<form method="GET" id="datePicker">
<input type="hidden" name="startDate" value="{{start_date.isoformat}}" id="startDate">
<input type="hidden" name="endDate" value="{{end_date.isoformat}}" id="endDate">
</form>
<input type="input" id="rangePicker" placeholder="Date range" class="input ~neutral !low cursor-pointer" readonly>
<style>
:root {
--litepickerMonthButtonHover: var(--color-urge);
--litepickerDayColorHover: var(--color-urge);
--litepickerDayIsTodayColor: var(--color-urge);
--litepickerDayIsInRange: var(--color-urge-normal-fill);
--litepickerDayIsStartBg: var(--color-urge);
--litepickerDayIsEndBg: var(--color-urge);
--litepickerButtonApplyBg: var(--color-urge);
}
</style>
<script>
var picker = new Litepicker({
element: document.getElementById('rangePicker'),
singleMode: false,
format: 'MMM D, YYYY',
maxDate: new Date(),
startDate: Date.parse(document.getElementById("startDate").getAttribute("value")),
endDate: Date.parse(document.getElementById("endDate").getAttribute("value")),
onSelect: function (startDate, endDate) {
document.getElementById("startDate").setAttribute("value", startDate.getFullYear() +
"-" + (startDate.getMonth() + 1) + "-" + startDate.getDate());
document.getElementById("endDate").setAttribute("value", endDate.getFullYear() + "-" +
(endDate.getMonth() + 1) + "-" + endDate.getDate());
document.getElementById("datePicker").submit();
}
});
</script>

View File

@ -1,45 +1,30 @@
{% load humanizelib %}
<article class="card ~neutral !low service mb-4">
{% with stats=service.get_daily_stats %}
<div class="md:flex justify-between">
<div class="mr-4 md:w-1/4">
<h3 class="heading text-2xl mr-2">
{% load humanize helpers %}
<a class="card ~neutral !low service mb-6" href="{% url 'core:service' service.uuid %}">
{% with stats=service.stats %}
<div class="md:flex justify-between items-center">
<div class="mr-4 md:w-4/12">
<h3 class="heading text-xl mr-2 mb-1">
{{service.name}}
</h3>
{% if stats.currently_online > 0 %}
<span class="badge ~positive">
{{stats.currently_online|intcomma}} online
</span>
{% elif stats.online == True %}
<span class="badge ~positive">
Online
</span>
{% elif stats.online == False %}
<span class="badge ~critical">
Offline
</span>
{% endif %}
{% include 'core/includes/stats_status_chip.html' %}
</div>
<div class="mr-2">
<p class="font-medium text-sm">Sessions</p>
<p class="text-xl text-purple-700 font-medium">{{stats.sessions|intcomma}}</p>
</div>
<div class="mr-2">
<p class="font-medium text-sm">Pages/Session</p>
<p class="text-xl text-purple-700 font-medium">{{stats.avg_hits_per_session|floatformat:"-1"}}</p>
<p class="text-xl text-purple-700 font-medium">{{stats.session_count|intcomma}}</p>
</div>
<div class="mr-2">
<p class="font-medium text-sm">Bounce Rate</p>
<p class="text-xl text-purple-700 font-medium">{{stats.bounce_rate_pct|floatformat:"-1"}}%</p>
</div>
<div class="mr-2">
<p class="font-medium text-sm">Avg. Session</p>
<p class="font-medium text-sm">Avg. Duration</p>
<p class="text-xl text-purple-700 font-medium">{{stats.avg_session_duration|naturaldelta}}</p>
</div>
<div class="mr-2">
<p class="font-medium text-sm">Uptime</p>
<p class="text-xl text-purple-700 font-medium">{{stats.uptime|floatformat:"-1"}}%</p>
<p class="text-xl text-purple-700 font-medium">99.9%</p>
</div>
</div>
{% endwith %}
</article>
</a>

View File

@ -1 +1,2 @@
<a class="portal !low w-full {% if url == request.get_full_path %}~urge active bg-gray-100{% endif %}" href="{{url}}">{{label}}</a>
<a class="portal {% if url == request.get_full_path %}~urge active bg-gray-100{% endif %}"
href="{{url}}">{{label}}</a><br>

View File

@ -0,0 +1,15 @@
{% load humanize %}
{% if stats.currently_online > 0 %}
<span class="chip ~positive">
{{stats.currently_online|intcomma}} online
</span>
{% elif stats.online == True %}
<span class="chip ~positive">
Online
</span>
{% elif stats.online == False %}
<span class="chip ~critical">
Offline
</span>
{% endif %}

View File

@ -1,13 +1,18 @@
{% extends "base.html" %}
{% block content %}
<h4 class="heading text-xl leading-none text-purple-600">Shynet</h4>
<h4 class="heading text-5xl leading-none">Dashboard</h4>
<div class="md:flex justify-between">
<h4 class="heading text-5xl leading-none">Shynet Dash</h4>
<div>
{% include 'core/includes/date_range.html' %}
</div>
</div>
<hr class="sep">
{% for service in user.owning_services.all %}
{% for service in services %}
{% include 'core/includes/service_overview.html' %}
{% empty %}
<p>You don't have any services.</p>
{% endfor %}
<a href="{% url 'core:service_create' %}" class="button ~neutral my-2">+ New Service</a>
{% endblock %}

View File

@ -0,0 +1,230 @@
{% extends "base.html" %}
{% load humanize helpers %}
{% block head_title %}{{service.name}}{% endblock %}
{% block content %}
<style>
.limited-height {
max-height: 400px !important;
overflow: scroll !important;
}
</style>
<div class="md:flex justify-between items-center" id="heading">
<div class="flex items-center">
<h3 class="heading leading-none mr-4">
{{service.name}}
</h3>
<div class='text-3xl'>
{% include 'core/includes/stats_status_chip.html' %}
</div>
</div>
<div class="flex items-center">
<div class="mr-2">{% include 'core/includes/date_range.html' %}</div>
<a href="{% url 'core:service_update' service.uuid %}" class="button field ~neutral !low w-auto">Manage &rarr;</a>
</div>
</div>
<hr class="sep h-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6" id="stats">
<article class="card ~neutral !low">
<p class="label">Sessions</p>
<p class="heading text-purple-600">
{{stats.session_count|intcomma}}
</p>
</article>
<article class="card ~neutral !low">
<p class="label">Hits</p>
<p class="heading text-purple-600">
{{stats.hit_count|intcomma}}
</p>
</article>
<article class="card ~neutral !low">
<p class="label">Avg. Load Time</p>
<p class="heading text-purple-600">
{{stats.avg_load_time|floatformat:"0"}}ms
</p>
</article>
<article class="card ~neutral !low">
<p class="label">Bounce Rate</p>
<p class="heading text-purple-600">{{stats.bounce_rate_pct|floatformat:"-1"}}%</p>
</article>
<article class="card ~neutral !low">
<p class="label">Avg. Duration</p>
<p class="heading text-purple-600">{{stats.avg_session_duration|naturaldelta}}</p>
</article>
<article class="card ~neutral !low">
<p class="label">Avg. Hits/Session</p>
<p class="heading text-purple-600">{{stats.avg_hits_per_session|floatformat:"-1"}}</p>
</article>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="card ~neutral !low limited-height">
<table class="table">
<thead class="text-sm">
<tr>
<th>Location</th>
<th>Hits</th>
</tr>
</thead>
<tbody>
{% for location in stats.locations %}
<tr>
<td>{{location.location}}</td>
<td>{{location.count|intcomma}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card ~neutral !low limited-height">
<table class="table">
<thead class="text-sm">
<tr>
<th>Referrer</th>
<th>Sessions</th>
</tr>
</thead>
<tbody>
{% for referrer in stats.referrers %}
<tr>
<td>{{referrer.referrer|default:"Direct"}}</td>
<td>{{referrer.count|intcomma}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card ~neutral !low limited-height">
<table class="table">
<thead class="text-sm">
<tr>
<th>Operating System</th>
<th>Sessions</th>
</tr>
</thead>
<tbody>
{% for os in stats.operating_systems %}
<tr>
<td>{{os.os|default:"Unknown"}}</td>
<td>{{os.count|intcomma}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card ~neutral !low limited-height">
<table class="table">
<thead class="text-sm">
<tr>
<th>Browser</th>
<th>Sessions</th>
</tr>
</thead>
<tbody>
{% for browser in stats.browsers %}
<tr>
<td>{{browser.browser|default:"Unknown"}}</td>
<td>{{browser.count|intcomma}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card ~neutral !low limited-height">
<table class="table">
<thead class="text-sm">
<tr>
<th>Device</th>
<th>Sessions</th>
</tr>
</thead>
<tbody>
{% for device in stats.devices %}
<tr>
<td>{{device.device|default:"Unknown"}}</td>
<td>{{device.count|intcomma}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card ~neutral !low limited-height">
<table class="table">
<thead class="text-sm">
<tr>
<th>Device Type</th>
<th>Sessions</th>
</tr>
</thead>
<tbody>
{% for device_type in stats.device_types %}
<tr>
<td>{{device_type.device_type|default:"Unknown"|title}}</td>
<td>{{device_type.count|intcomma}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card ~neutral !low limited-height">
<table class="table">
<thead>
<th>Time</th>
<th>Identity</th>
<th>Duration</th>
<th>Network</th>
<th>IP</th>
<th>Hits</th>
</thead>
<tbody>
{% for session in stats.sessions %}
<tr>
<td>
{{session.start_time|date:"M j Y, g:i a"|capfirst}}
{% if session.is_currently_active %}
<span class="badge ~positive">Online</span>
{% endif %}
</td>
<td>{{session.identifier|default:"Anonymous"}}</td>
<td>{{session.duration|naturaldelta}}</td>
<td>{{session.country|flag_emoji}} {{session.asn|default:"Unknown"}}</td>
<td>{{session.ip}}</td>
<td>{{session.hit_set.count|intcomma}}</td>
</tr>
{% endfor %}
{% for session in stats.sessions %}
<tr>
<td>
{{session.start_time|date:"M j Y, g:i a"|capfirst}}
{% if session.is_currently_active %}
<span class="badge ~positive">Online</span>
{% endif %}
</td>
<td>{{session.identifier|default:"Anonymous"}}</td>
<td>{{session.duration|naturaldelta}}</td>
<td>{{session.country|flag_emoji}} {{session.asn|default:"Unknown"}}</td>
<td>{{session.ip}}</td>
<td>{{session.hit_set.count|intcomma}}</td>
</tr>
{% endfor %}
{% for session in stats.sessions %}
<tr>
<td>
{{session.start_time|date:"M j Y, g:i a"|capfirst}}
{% if session.is_currently_active %}
<span class="badge ~positive">Online</span>
{% endif %}
</td>
<td>{{session.identifier|default:"Anonymous"}}</td>
<td>{{session.duration|naturaldelta}}</td>
<td>{{session.country|flag_emoji}} {{session.asn|default:"Unknown"}}</td>
<td>{{session.ip}}</td>
<td>{{session.hit_set.count|intcomma}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% load a17t_tags %}
{% block head_title %}Create Service{% endblock %}
{% block content %}
<h4 class="heading text-5xl leading-none">Create Service</h4>
<hr class="sep">
<form class="card ~neutral !low p-0 max-w-xl" method="POST">
{% csrf_token %}
<div class="p-4">
{{form|a17t}}
</div>
<div class="section ~urge !normal p-4">
<button type="submit" class="button ~urge !high">Create</button>
<a href="{% url 'core:dashboard' %}" class="button ~urge !low">Cancel</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% load a17t_tags %}
{% block head_title %}{{object.name}} Management{% endblock %}
{% block content %}
<div class="md:flex justify-between items-center" id="heading">
<h3 class="heading leading-none mr-4">
{{object.name}}
</h3>
<a href="{% url 'core:service' object.uuid %}" class="button field ~neutral !low w-auto">View &rarr;</a>
</div>
<hr class="sep">
<div class="max-w-xl content">
<h5>Analytics Installation</h5>
<p>(At the end of <code>&lt;body&gt;</code>)
<pre
class="text-sm"><code>{% filter force_escape %}<noscript><img src="{{base_url}}/ingress/fc4008d3-f2fa-4500-9968-d96719e3819c/pixel.gif"></noscript>
<script src="{{base_url}}/ingress/fc4008d3-f2fa-4500-9968-d96719e3819c/identifier/script.js"></script>{% endfilter %}</pre></code>
</div>
<hr class="sep">
<form class="card ~neutral !low p-0 max-w-xl" method="POST">
{% csrf_token %}
<div class="p-4">
{{form|a17t}}
</div>
<div class="section ~neutral !normal p-4">
<button type="submit" class="button ~neutral !high">Save</button>
<a href="{% url 'core:service' object.uuid %}" class="button ~neutral !low">Cancel</a>
</div>
</form>
{% endblock %}

View File

View File

@ -0,0 +1,27 @@
import flag
from django import template
from django.utils import timezone
register = template.Library()
@register.filter
def naturaldelta(timedelta):
if isinstance(timedelta, timezone.timedelta):
seconds = timedelta.seconds
else:
seconds = timedelta
string = ""
if seconds // 3600 > 0:
string += "{:02.0f}:".format(seconds // 3600)
string += "{:02.0f}:".format((seconds % 3600) // 60)
string += "{:02.0f}".format(seconds % 60)
return string
@register.filter
def flag_emoji(isocode):
try:
return flag.flag(isocode)
except:
return ""

View File

@ -6,4 +6,11 @@ from . import views
urlpatterns = [
path("", views.IndexView.as_view(), name="index"),
path("dash/", views.DashboardView.as_view(), name="dashboard"),
path("dash/service/new/", views.ServiceCreateView.as_view(), name="service_create"),
path("dash/service/<pk>/", views.ServiceView.as_view(), name="service"),
path(
"dash/service/<pk>/manage/",
views.ServiceUpdateView.as_view(),
name="service_update",
),
]

View File

@ -1,10 +1,53 @@
from django.views.generic import TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import reverse
from django.utils import timezone
from django.views.generic import (CreateView, DetailView, TemplateView,
UpdateView)
from .forms import ServiceForm
from .mixins import BaseUrlMixin, DateRangeMixin
from .models import Service
class IndexView(TemplateView):
template_name = "core/pages/index.html"
class DashboardView(LoginRequiredMixin, TemplateView):
class DashboardView(LoginRequiredMixin, DateRangeMixin, TemplateView):
template_name = "core/pages/dashboard.html"
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data["services"] = Service.objects.filter(owner=self.request.user)
for service in data["services"]:
service.stats = service.get_core_stats(data["start_date"], data["end_date"])
return data
class ServiceCreateView(LoginRequiredMixin, CreateView):
model = Service
form_class = ServiceForm
template_name = "core/pages/service_create.html"
def form_valid(self, form):
form.instance.owner = self.request.user
return super().form_valid(form)
class ServiceView(LoginRequiredMixin, DateRangeMixin, DetailView):
model = Service
template_name = "core/pages/service.html"
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data["stats"] = self.object.get_core_stats(data["start_date"], data["end_date"])
return data
class ServiceUpdateView(LoginRequiredMixin, BaseUrlMixin, UpdateView):
model = Service
form_class = ServiceForm
template_name = "core/pages/service_update.html"
def get_success_url(self):
return reverse("core:service", kwargs={"uuid": self.object.uuid})

View File

@ -12,6 +12,9 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
import os
# Messages
from django.contrib.messages import constants as messages
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -38,7 +41,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
"django_humanize",
"django.contrib.humanize",
"a17t",
"core",
"analytics",
@ -151,12 +154,10 @@ CELERY_REDIS_SOCKET_TIMEOUT = 15
MAXMIND_CITY_DB = os.getenv("MAXMIND_CITY_DB")
MAXMIND_ASN_DB = os.getenv("MAXMIND_ASN_DB")
# Messages
from django.contrib.messages import constants as messages
MESSAGE_TAGS = {
messages.INFO: '~info',
messages.INFO: "~info",
messages.WARNING: "~warning",
messages.ERROR: "~critical",
messages.SUCCESS: "~positive",
}

View File

@ -10,14 +10,6 @@
<noscript>
<img src="http://localhost:8000/ingress/fc4008d3-f2fa-4500-9968-d96719e3819c/pixel.gif">
</noscript>
<script>
var shynetSessionMetadata = {
session: "this is some session metadata",
};
var shynetHitMetadata = {
hit: "this is some hit metadata",
};
</script>
<script src="http://localhost:8000/ingress/fc4008d3-f2fa-4500-9968-d96719e3819c/identifier/script.js"></script>
</body>