Add basic stats list

This commit is contained in:
R. Miles McCain 2020-04-11 16:42:14 -04:00
parent 6c3064f3ea
commit 15fa8226e0
No known key found for this signature in database
GPG Key ID: 91CB47BDDF2671A5
16 changed files with 230 additions and 38 deletions

View File

@ -15,6 +15,7 @@ django-ipware = "*"
pyyaml = "*"
ua-parser = "*"
user-agents = "*"
django-humanize = "*"
[requires]
python_version = "3.6"

16
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "03f23e0c7409df9eb5a85b3df03d1975a1fe5563496602492cdcff46b8c5c829"
"sha256": "13b07e6285b739fe6f4987482aa448f1773d051de2de4806126a0e4bded150cf"
},
"pipfile-spec": 6,
"requires": {
@ -81,6 +81,13 @@
"index": "pypi",
"version": "==0.41.0"
},
"django-humanize": {
"hashes": [
"sha256:32491bf0209b89a277f7bfdab7fd6d4cc7944bb037f742d62e8e447a575c0028"
],
"index": "pypi",
"version": "==0.1.2"
},
"django-ipware": {
"hashes": [
"sha256:a7c7a8fd019dbdc9c357e6e582f65034e897572fc79a7e467674efa8aef9d00b"
@ -96,6 +103,13 @@
"index": "pypi",
"version": "==3.0.0"
},
"humanize": {
"hashes": [
"sha256:98b7ac9d1a70ad62175c8e0dd44beebbd92418727fc4e214468dfb2baa8ebfb5",
"sha256:bc2a1ff065977011de2bc36197a4b14730c54bfc46ab12a153376684573a2dab"
],
"version": "==2.3.0"
},
"idna": {
"hashes": [
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",

View File

@ -1,2 +1,3 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/a17t@0.1.3/dist/a17t.css">
<script async src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/a17t@latest/dist/a17t.css">

View File

@ -0,0 +1,34 @@
# Generated by Django 3.0.5 on 2020-04-11 19:41
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('analytics', '0003_auto_20200410_1325'),
]
operations = [
migrations.RenameField(
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',
),
migrations.AddField(
model_name='hit',
name='last_seen',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View File

@ -18,7 +18,7 @@ class Session(models.Model):
identifier = models.TextField(blank=True)
# Time
first_seen = models.DateTimeField(auto_now_add=True)
start_time = models.DateTimeField(auto_now_add=True)
last_seen = models.DateTimeField(auto_now_add=True)
# Core request information
@ -40,8 +40,8 @@ class Hit(models.Model):
session = models.ForeignKey(Session, on_delete=models.CASCADE)
# Base request information
start = models.DateTimeField(auto_now_add=True)
duration = models.FloatField(default=0.0) # Seconds spent on page
start_time = models.DateTimeField(auto_now_add=True)
last_seen = models.DateTimeField(auto_now_add=True)
heartbeats = models.IntegerField(default=0)
tracker = models.TextField() # Tracking pixel or JS

View File

@ -46,7 +46,7 @@ def ingress_request(
try:
ip_data = _geoip2_lookup(ip)
service = Service.objects.get(uuid=service_uuid)
service = Service.objects.get(uuid=service_uuid, status=Service.ACTIVE)
log.debug(f"Linked to service {service}")
# Create or update session
@ -95,7 +95,7 @@ def ingress_request(
# this is a heartbeat.
log.debug("Hit is a heartbeat; updating old hit with new data...")
hit.heartbeats += 1
hit.duration = (timezone.now() - hit.start).total_seconds()
hit.last_seen = timezone.now()
hit.save()
if hit is None:
log.debug("Hit is a page load; creating new hit...")

View File

@ -2,6 +2,9 @@ import uuid
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
def _default_uuid():
@ -35,3 +38,63 @@ class Service(models.Model):
status = models.CharField(
max_length=2, choices=SERVICE_STATUSES, default=ACTIVE, db_index=True
)
def __str__(self):
return self.name
def get_daily_stats(self):
return self.get_core_stats(
start_time=timezone.now() - timezone.timedelta(days=1)
)
def get_core_stats(self, start_time=None, end_time=None):
if start_time is None:
start_time = timezone.now() - timezone.timedelta(days=30)
if end_time is None:
end_time = timezone.now()
Session = apps.get_model("analytics", "Session")
Hit = apps.get_model("analytics", "Hit")
currently_online = Session.objects.filter(
service=self, start_time__gt=timezone.now() - timezone.timedelta(seconds=10)
).count()
sessions = Session.objects.filter(
service=self, start_time__gt=start_time, start_time__lt=end_time
)
session_count = sessions.count()
hits = Hit.objects.filter(
session__service=self, start_time__lt=end_time, start_time__gt=start_time
)
hit_count = hits.count()
bounces = sessions.annotate(hit_count=models.Count("hit")).filter(hit_count=1)
bounce_count = bounces.count()
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(
[
(session.last_seen - session.start_time).total_seconds()
for session in sessions
]
)
/ session_count
)
return {
"currently_online": currently_online,
"sessions": session_count,
"hits": hit_count,
"avg_hits_per_session": hit_count / (max(session_count, 1)),
"bounce_rate_pct": bounce_count * 100 / session_count,
"avg_session_duration": avg_session_duration,
"uptime": 99.9,
"online": True,
}

View File

@ -26,18 +26,18 @@
{{ emailaddress.email }}
{% if emailaddress.verified %}
<span class="support ~positive">({% trans "Verified" %})</span>
<span class="badge ~positive">{% trans "Verified" %}</span>
{% else %}
<span class="support ~warning">({% trans "Unverified" %})</span>
<span class="badge ~critical">{% trans "Unverified" %}</span>
{% endif %}
{% if emailaddress.primary %}<span class="support ~urge">({% trans "Primary" %})</span>{% endif %}
{% if emailaddress.primary %}<span class="badge ~urge">{% trans "Primary" %}</span>{% endif %}
</label>
</div>
{% endfor %}
<div class="block">
<div class="block mt-4">
<button class="button ~urge mb-1" type="submit" name="action_primary">{% trans 'Make Primary' %}</button>
<button class="button ~info mb-1" type="submit" name="action_send">{% trans 'Re-send Verification' %}</button>
<button class="button ~info mb-1" type="submit" name="action_send">{% trans 'Resend Verification' %}</button>
<button class="button ~critical mb-1" type="submit" name="action_remove">{% trans 'Remove' %}</button>
</div>
</fieldset>
@ -57,7 +57,7 @@
<form method="post" action="{% url 'account_email' %}" class="add_email max-w-lg">
{% csrf_token %}
{{ form|a17t }}
<button name="button ~neutral !high" type="submit">{% trans "Add Email" %}</button>
<button name="action_add" class="button ~neutral !high" type="submit">{% trans "Add Email" %}</button>
</form>
{% endblock %}

View File

@ -8,7 +8,7 @@
{% block content %}
<div class="card ~neutral !low max-w-lg">
<h1 class="heading mb-3">{% trans "Sign In" %}</h1>
<form class="login" method="POST" action="{% url 'account_login' %} max-w-lg">
<form class="login" method="POST" action="{% url 'account_login' %}">
{% csrf_token %}
{{ form|a17t }}
{% if redirect_field_value %}

View File

@ -14,32 +14,44 @@
<body class="bg-gray-100 min-h-full">
{% block body %}
<section class="max-w-4xl mx-auto px-6 md:py-12">
{% if messages %}
<div>
{% for message in messages %}
<article class="card {{message.tags}} !high mb-2 w-full">{{message}}</article>
{% endfor %}
</ul>
</div>
{% endif %}
<section class="max-w-screen-lg mx-auto px-6 md:py-12 md:flex">
<aside class="w-2/12 mr-4 block text-sm">
<a class="icon ~urge ml-6 mb-8 mt-3" href="{% url 'core:dashboard' %}">
<i class="fas fa-low-vision fa-3x text-purple-600"></i>
</a>
<div>
<strong>Menu:</strong>
<ul>
{% if user.is_authenticated %}
<li><a href="{% url 'account_email' %}">Change Email</a></li>
<li><a href="{% url 'account_logout' %}">Sign Out</a></li>
{% else %}
<li><a href="{% url 'account_login' %}">Sign In</a></li>
<li><a href="{% url 'account_signup' %}">Sign Up</a></li>
{% endif %}
</ul>
{% if user.is_authenticated %}
{% url 'account_email' as url %}
{% include 'core/includes/sidebar_portal.html' with label="Emails" url=url %}
{% url 'account_logout' as url %}
{% include 'core/includes/sidebar_portal.html' with label="Log Out" url=url %}
{% else %}
{% url 'account_login' as url %}
{% include 'core/includes/sidebar_portal.html' with label="Log In" url=url %}
{% url 'account_signup' as url %}
{% include 'core/includes/sidebar_portal.html' with label="Sign Up" url=url %}
{% endif %}
</aside>
<div class="flex-grow">
{% if messages %}
<div>
{% for message in messages %}
<article class="card {{message.tags}} !high mb-2 w-full">{{message}}</article>
{% endfor %}
</ul>
</div>
{% endif %}
<main>
{% block content %}
{% endblock %}
</main>
</div>
<main>
{% block content %}
{% endblock %}
</main>
</section>
{% endblock %}
{% block extra_body %}

View File

@ -0,0 +1,45 @@
{% 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-gray-700 text-2xl mr-2 leading-none">
{{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 %}
</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>
</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="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>
</div>
</div>
{% endwith %}
</article>

View File

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

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h4 class="heading text-lg leading-none text-purple-600">Shynet</h4>
<h4 class="heading text-4xl leading-none">Dashboard</h4>
<hr class="sep">
{% for service in user.owning_services.all %}
{% include 'core/includes/service_overview.html' %}
{% empty %}
<p>You don't have any services.</p>
{% endfor %}
{% endblock %}

View File

@ -5,4 +5,5 @@ from . import views
urlpatterns = [
path("", views.IndexView.as_view(), name="index"),
path("dash/", views.DashboardView.as_view(), name="dashboard"),
]

View File

@ -1,5 +1,10 @@
from django.views.generic import TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
class IndexView(TemplateView):
template_name = "core/pages/index.html"
class DashboardView(LoginRequiredMixin, TemplateView):
template_name = "core/pages/dashboard.html"

View File

@ -38,6 +38,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
"django_humanize",
"a17t",
"core",
"analytics",
@ -158,3 +159,4 @@ MESSAGE_TAGS = {
messages.ERROR: "~critical",
messages.SUCCESS: "~positive",
}