From 4a00e5180676ec1b700656db888159d4512b527e Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Wed, 2 Apr 2025 22:02:35 +0200
Subject: [PATCH 01/19] introduce django and serve the same frontend
---
.gitignore | 5 +
meshping/manage.py | 22 ++
meshping/meshping/__init__.py | 0
meshping/meshping/apps.py | 10 +
meshping/meshping/asgi.py | 16 +
meshping/meshping/jinja2.py | 15 +
meshping/meshping/migrations/0001_initial.py | 54 +++
meshping/meshping/migrations/__init__.py | 0
meshping/meshping/models.py | 34 ++
meshping/meshping/settings.py | 95 +++++
meshping/meshping/templates/index.html.j2 | 216 +++++++++++
meshping/meshping/urls.py | 7 +
meshping/meshping/views.py | 28 ++
meshping/meshping/wsgi.py | 16 +
meshping/ui/package-lock.json | 359 +++++++++++++++++++
meshping/ui/package.json | 18 +
meshping/ui/src/main.js | 184 ++++++++++
requirements.txt | 4 +
18 files changed, 1083 insertions(+)
create mode 100755 meshping/manage.py
create mode 100644 meshping/meshping/__init__.py
create mode 100644 meshping/meshping/apps.py
create mode 100644 meshping/meshping/asgi.py
create mode 100644 meshping/meshping/jinja2.py
create mode 100644 meshping/meshping/migrations/0001_initial.py
create mode 100644 meshping/meshping/migrations/__init__.py
create mode 100644 meshping/meshping/models.py
create mode 100644 meshping/meshping/settings.py
create mode 100644 meshping/meshping/templates/index.html.j2
create mode 100644 meshping/meshping/urls.py
create mode 100644 meshping/meshping/views.py
create mode 100644 meshping/meshping/wsgi.py
create mode 100644 meshping/ui/package-lock.json
create mode 100644 meshping/ui/package.json
create mode 100644 meshping/ui/src/main.js
diff --git a/.gitignore b/.gitignore
index 6d529b5..0f04a06 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,8 @@ build/
ui/node_modules/
db/
+
+meshping/ui/node_modules/
+meshping/db/
+
+venv/
diff --git a/meshping/manage.py b/meshping/manage.py
new file mode 100755
index 0000000..324b250
--- /dev/null
+++ b/meshping/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'meshping.settings')
+ try:
+ 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()
diff --git a/meshping/meshping/__init__.py b/meshping/meshping/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/meshping/meshping/apps.py b/meshping/meshping/apps.py
new file mode 100644
index 0000000..6b2b4a8
--- /dev/null
+++ b/meshping/meshping/apps.py
@@ -0,0 +1,10 @@
+from django.apps import AppConfig
+
+
+class MeshpingConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'meshping'
+
+ def ready(self):
+ # TODO start background threads for ping, traceroute, peers
+ pass
diff --git a/meshping/meshping/asgi.py b/meshping/meshping/asgi.py
new file mode 100644
index 0000000..1afae58
--- /dev/null
+++ b/meshping/meshping/asgi.py
@@ -0,0 +1,16 @@
+"""
+ASGI config for meshping project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'meshping.settings')
+
+application = get_asgi_application()
diff --git a/meshping/meshping/jinja2.py b/meshping/meshping/jinja2.py
new file mode 100644
index 0000000..f575cba
--- /dev/null
+++ b/meshping/meshping/jinja2.py
@@ -0,0 +1,15 @@
+from jinja2 import Environment
+from django.urls import reverse
+from django.templatetags.static import static
+
+
+def environment(**options):
+ env = Environment(
+ variable_start_string='{[',
+ variable_end_string=']}',
+ **options)
+ env.globals.update({
+ "static": static,
+ "url": reverse
+ })
+ return env
diff --git a/meshping/meshping/migrations/0001_initial.py b/meshping/meshping/migrations/0001_initial.py
new file mode 100644
index 0000000..3b3b4b4
--- /dev/null
+++ b/meshping/meshping/migrations/0001_initial.py
@@ -0,0 +1,54 @@
+# Generated by Django 5.1.7 on 2025-04-02 18:07
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Target',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('addr', models.CharField(max_length=255, unique=True)),
+ ('name', models.CharField(max_length=255)),
+ ],
+ options={
+ 'unique_together': {('addr', 'name')},
+ },
+ ),
+ migrations.CreateModel(
+ name='Statistics',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('field', models.CharField(max_length=255)),
+ ('value', models.FloatField()),
+ ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meshping.target')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Meta',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('field', models.CharField(max_length=255)),
+ ('value', models.CharField(max_length=255)),
+ ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meshping.target')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='Histogram',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('timestamp', models.IntegerField()),
+ ('bucket', models.IntegerField()),
+ ('count', models.IntegerField(default=1)),
+ ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='meshping.target')),
+ ],
+ ),
+ ]
diff --git a/meshping/meshping/migrations/__init__.py b/meshping/meshping/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py
new file mode 100644
index 0000000..aa094f2
--- /dev/null
+++ b/meshping/meshping/models.py
@@ -0,0 +1,34 @@
+from django.db import models
+
+
+# TODO decide on max_length, even though ignored by sqlite
+class Target(models.Model):
+ addr = models.CharField(max_length=255, unique=True)
+ name = models.CharField(max_length=255)
+
+ class Meta:
+ unique_together = ('addr', 'name')
+
+
+# TODO uniqueness constraint `UNIQUE (target_id, timestamp, bucket)`
+class Histogram(models.Model):
+ target = models.ForeignKey(Target, on_delete=models.CASCADE)
+ timestamp = models.IntegerField()
+ bucket = models.IntegerField()
+ count = models.IntegerField(default=1)
+
+
+# TODO decide on max_length, even though ignored by sqlite
+# TODO uniqueness constraint `UNIQUE (target_id, field)`
+class Statistics(models.Model):
+ target = models.ForeignKey(Target, on_delete=models.CASCADE)
+ field = models.CharField(max_length=255)
+ value = models.FloatField()
+
+
+# TODO check if the CharField maps to SQLite TEXT + decide on max_length
+# TODO uniqueness constraint `UNIQUE (target_id, field)`
+class Meta(models.Model):
+ target = models.ForeignKey(Target, on_delete=models.CASCADE)
+ field = models.CharField(max_length=255)
+ value = models.CharField(max_length=255)
diff --git a/meshping/meshping/settings.py b/meshping/meshping/settings.py
new file mode 100644
index 0000000..be9ec64
--- /dev/null
+++ b/meshping/meshping/settings.py
@@ -0,0 +1,95 @@
+from pathlib import Path
+import os
+
+
+BASE_DIR = Path(__file__).resolve().parent.parent
+
+SECRET_KEY = 'django-insecure-2svcr!j9%^_(^)$%)vvt1j&0$w)&_l&oxacnb1mkurd4f--xmg'
+DEBUG = True
+ALLOWED_HOSTS = ['*']
+
+INSTALLED_APPS = [
+# 'django.contrib.admin',
+# 'django.contrib.auth',
+# 'django.contrib.contenttypes',
+# 'django.contrib.sessions',
+# 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'meshping',
+]
+
+#MIDDLEWARE = [
+# 'django.middleware.security.SecurityMiddleware',
+# 'django.contrib.sessions.middleware.SessionMiddleware',
+# 'django.middleware.common.CommonMiddleware',
+# 'django.middleware.csrf.CsrfViewMiddleware',
+# 'django.contrib.auth.middleware.AuthenticationMiddleware',
+# 'django.contrib.messages.middleware.MessageMiddleware',
+# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+#]
+
+ROOT_URLCONF = 'meshping.urls'
+
+# TODO the APP_DIRS seem to not work as I imagined for the jinja2 backend
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.jinja2.Jinja2",
+ "DIRS": [os.path.join(BASE_DIR, 'meshping/templates'),],
+ "APP_DIRS": False,
+ "OPTIONS": {
+ "environment": "meshping.jinja2.environment",
+ },
+ },
+# {
+# 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+# 'DIRS': [],
+# 'APP_DIRS': True,
+# 'OPTIONS': {
+# 'context_processors': [
+# 'django.template.context_processors.debug',
+# 'django.template.context_processors.request',
+## 'django.contrib.auth.context_processors.auth',
+## 'django.contrib.messages.context_processors.messages',
+# ],
+# },
+# },
+]
+#WSGI_APPLICATION = 'meshping.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': BASE_DIR / 'db' / 'db.sqlite3',
+ }
+}
+
+#AUTH_PASSWORD_VALIDATORS = [
+# {
+# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+# },
+# {
+# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+# },
+# {
+# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+# },
+# {
+# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+# },
+#]
+
+#LANGUAGE_CODE = 'en-us'
+#TIME_ZONE = 'UTC'
+#USE_I18N = True
+#USE_TZ = True
+
+STATIC_URL = 'ui/'
+STATICFILES_DIRS = [
+ BASE_DIR / "ui",
+]
+
+DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
diff --git a/meshping/meshping/templates/index.html.j2 b/meshping/meshping/templates/index.html.j2
new file mode 100644
index 0000000..fa19cc6
--- /dev/null
+++ b/meshping/meshping/templates/index.html.j2
@@ -0,0 +1,216 @@
+
+
+
+ {[ Hostname ]} — Meshping
+
+
+
+
+
+
+
+
+
+
+
+
Meshping: {[ Hostname ]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | |
+ Hop |
+ Info |
+ |
+
+
+ | {{ hop.distance }}
+ |
+ {{ hop.name }}
+ {[ icons['exclamation-circle.svg'] ]}
+ {[ icons['x-circle.svg'] ]}
+
+ {{ hop.address }}
+
+
+ AS {{ hop.whois.asn }}:
+ {{ hop.whois.network.name }}
+
+ |
+
+ {{ hop.max_rtt }} ms
+ {{ hop.pmtud.mtu }} B
+
+ {{ hop.pmtud.mtu }} B {[ icons['x-circle.svg'] ]}
+
+
+ {{ hop.pmtud.mtu }} B {[ icons['arrow-clockwise.svg'] ]}
+
+
+ {{ hop.pmtud.mtu }} B {[ icons['exclamation-circle.svg'] ]}
+
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ github.com/Svedrin/meshping
+ Meshping by Michael Ziegler
+
+
+
diff --git a/meshping/meshping/urls.py b/meshping/meshping/urls.py
new file mode 100644
index 0000000..d8757d0
--- /dev/null
+++ b/meshping/meshping/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+from . import views
+
+
+urlpatterns = [
+ path('', views.index, name='index'),
+]
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
new file mode 100644
index 0000000..8c5d76b
--- /dev/null
+++ b/meshping/meshping/views.py
@@ -0,0 +1,28 @@
+import os
+import socket
+
+from django.http import HttpResponse
+from django.template import loader
+from markupsafe import Markup
+from .models import Target
+
+
+# TODO do not load icons from disk for every request
+# TODO find a better method for finding the icons to not have an absolute path here
+def index(request):
+ template = loader.get_template('index.html.j2')
+ # icons_dir = "/opt/meshping/ui/node_modules/bootstrap-icons/icons/"
+ icons_dir = "../ui/node_modules/bootstrap-icons/icons/"
+ icons_dir = os.path.join(os.path.dirname(__file__), icons_dir)
+ print(icons_dir)
+ icons = dict(
+ icons={
+ filename: Markup(open(os.path.join(icons_dir, filename), "r").read())
+ for filename in os.listdir(icons_dir)
+ }
+ )
+ context = {
+ "Hostname": socket.gethostname(),
+ "icons": icons,
+ }
+ return HttpResponse(template.render(context, request))
diff --git a/meshping/meshping/wsgi.py b/meshping/meshping/wsgi.py
new file mode 100644
index 0000000..c227ee0
--- /dev/null
+++ b/meshping/meshping/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for meshping project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'meshping.settings')
+
+application = get_wsgi_application()
diff --git a/meshping/ui/package-lock.json b/meshping/ui/package-lock.json
new file mode 100644
index 0000000..587746c
--- /dev/null
+++ b/meshping/ui/package-lock.json
@@ -0,0 +1,359 @@
+{
+ "name": "meshping-ui",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "meshping-ui",
+ "version": "0.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "bootstrap": "^4.4.1",
+ "bootstrap-icons": "^1.11.3",
+ "jquery": "^3.5.0",
+ "vue": "^2.6.11",
+ "vue-resource": "^1.5.3"
+ }
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@szmarczak/http-timer": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
+ "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
+ "dependencies": {
+ "defer-to-connect": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@types/cacheable-request": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
+ "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
+ "dependencies": {
+ "@types/http-cache-semantics": "*",
+ "@types/keyv": "^3.1.4",
+ "@types/node": "*",
+ "@types/responselike": "^1.0.0"
+ }
+ },
+ "node_modules/@types/http-cache-semantics": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
+ "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
+ },
+ "node_modules/@types/keyv": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
+ "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "18.11.18",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz",
+ "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA=="
+ },
+ "node_modules/@types/responselike": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz",
+ "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/bootstrap": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.4.1.tgz",
+ "integrity": "sha512-tbx5cHubwE6e2ZG7nqM3g/FZ5PQEDMWmMGNrCUBVRPHXTJaH7CBDdsLeu3eCh3B1tzAxTnAbtmrzvWEvT2NNEA==",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/bootstrap"
+ },
+ "peerDependencies": {
+ "jquery": "1.9.1 - 3",
+ "popper.js": "^1.16.0"
+ }
+ },
+ "node_modules/bootstrap-icons": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz",
+ "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/twbs"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/bootstrap"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/cacheable-lookup": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
+ "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
+ "engines": {
+ "node": ">=10.6.0"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz",
+ "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==",
+ "dependencies": {
+ "clone-response": "^1.0.2",
+ "get-stream": "^5.1.0",
+ "http-cache-semantics": "^4.0.0",
+ "keyv": "^4.0.0",
+ "lowercase-keys": "^2.0.0",
+ "normalize-url": "^6.0.1",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/clone-response": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
+ "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
+ "dependencies": {
+ "mimic-response": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/decompress-response/node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/defer-to-connect": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/got": {
+ "version": "11.8.6",
+ "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
+ "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
+ "dependencies": {
+ "@sindresorhus/is": "^4.0.0",
+ "@szmarczak/http-timer": "^4.0.5",
+ "@types/cacheable-request": "^6.0.1",
+ "@types/responselike": "^1.0.0",
+ "cacheable-lookup": "^5.0.3",
+ "cacheable-request": "^7.0.2",
+ "decompress-response": "^6.0.0",
+ "http2-wrapper": "^1.0.0-beta.5.2",
+ "lowercase-keys": "^2.0.0",
+ "p-cancelable": "^2.0.0",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
+ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
+ },
+ "node_modules/http2-wrapper": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
+ "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
+ "dependencies": {
+ "quick-lru": "^5.1.1",
+ "resolve-alpn": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ }
+ },
+ "node_modules/jquery": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.0.tgz",
+ "integrity": "sha512-Xb7SVYMvygPxbFMpTFQiHh1J7HClEaThguL15N/Gg37Lri/qKyhRGZYzHRyLH8Stq3Aow0LsHO2O2ci86fCrNQ=="
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
+ },
+ "node_modules/keyv": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz",
+ "integrity": "sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/lowercase-keys": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
+ "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
+ "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/normalize-url": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
+ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
+ "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+ "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/quick-lru": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/resolve-alpn": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="
+ },
+ "node_modules/responselike": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
+ "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
+ "dependencies": {
+ "lowercase-keys": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/vue": {
+ "version": "2.6.11",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.11.tgz",
+ "integrity": "sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==",
+ "deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details."
+ },
+ "node_modules/vue-resource": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/vue-resource/-/vue-resource-1.5.3.tgz",
+ "integrity": "sha512-REhTuEuYSpwmEH/VN4fgDQVC/VXxDK/xsguuiDPnINxOwy1s0CSu//p++osTUkiAXi6d/vptwBpb0AcBIDsXzw==",
+ "dependencies": {
+ "got": ">=8.0 <12.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
+ }
+ }
+}
diff --git a/meshping/ui/package.json b/meshping/ui/package.json
new file mode 100644
index 0000000..8a7a105
--- /dev/null
+++ b/meshping/ui/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "meshping-ui",
+ "version": "0.0.1",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "",
+ "license": "MIT",
+ "dependencies": {
+ "bootstrap": "^4.4.1",
+ "bootstrap-icons": "^1.11.3",
+ "jquery": "^3.5.0",
+ "vue": "^2.6.11",
+ "vue-resource": "^1.5.3"
+ }
+}
diff --git a/meshping/ui/src/main.js b/meshping/ui/src/main.js
new file mode 100644
index 0000000..3ca4097
--- /dev/null
+++ b/meshping/ui/src/main.js
@@ -0,0 +1,184 @@
+window.app = new Vue({
+ el: '#app',
+ data: {
+ hostname: window.meshping_hostname,
+ error_msg: "",
+ success_msg: "",
+ last_update: 0,
+ search: localStorage.getItem("meshping_search") || "",
+ targets_all: [],
+ targets_filtered: [],
+ add_tgt_name: "",
+ add_tgt_addr: "",
+ comparing: false,
+ creating: false,
+ route_target: {name: "", "traceroute": []},
+ },
+ methods: {
+ update_targets: async function () {
+ var response = await this.$http.get('/api/targets');
+ var json = await response.json();
+ this.targets_all = json.targets;
+ this.last_update = new Date();
+ },
+ reapply_filters: function() {
+ if( this.search === "" ){
+ // Make a copy of the array, or else chrome goes 100% CPU in sort() :o
+ this.targets_filtered = this.targets_all.slice();
+ } else {
+ var search = this.search.toLowerCase();
+ this.targets_filtered = this.targets_all.filter(function(target){
+ return (
+ target.name.toLowerCase().includes(search) ||
+ target.addr.includes(search)
+ );
+ });
+ }
+ var ip_as_filled_str = function(ipaddr) {
+ if (!ipaddr.includes(":")) {
+ // IPv4
+ return (ipaddr
+ .split(".")
+ .map(x => x.toString().padStart(3, "0"))
+ .join("")
+ );
+ } else {
+ // IPv6
+ return (ipaddr
+ .split(":")
+ .map(x => x.toString().padStart(4, "0"))
+ .join("")
+ );
+ }
+ }
+ this.targets_filtered.sort(function(a, b){
+ return ip_as_filled_str(a.addr).localeCompare(ip_as_filled_str(b.addr));
+ });
+ },
+ delete_target: async function(target) {
+ var message = `Delete target ${target.name} (${target.addr})?`;
+ if (confirm(message)) {
+ var response = await this.$http.delete(`/api/targets/${target.addr}`);
+ var json = await response.json();
+ if (json.success) {
+ this.show_success(`Success! Deleted target ${target.name} (${target.addr}).`);
+ this.update_targets();
+ }
+ }
+ },
+ create_target: async function() {
+ this.creating = true;
+ var target_str = this.add_tgt_name;
+ if (this.add_tgt_addr !== "") {
+ target_str += "@" + this.add_tgt_addr;
+ }
+ var response = await this.$http.post('/api/targets', {
+ "target": target_str
+ });
+ var json = await response.json();
+ this.creating = false;
+ if (json.success) {
+ this.add_tgt_name = "";
+ this.add_tgt_addr = "";
+ this.show_success(
+ "Success! Added targets: " +
+ json.targets.map(tgt => `- ${tgt}
`).join("") +
+ "
"
+ );
+ this.update_targets();
+ } else {
+ this.show_error(
+ `Error! Could not add target ${json.target}: ` +
+ json.error
+ );
+ setTimeout(() => $('#add_tgt_name').focus(), 50);
+ }
+ },
+ clear_stats: async function() {
+ var response = await this.$http.delete('/api/stats');
+ var json = await response.json();
+ if (json.success) {
+ this.show_success(
+ "Success!Stats are cleared."
+ );
+ this.update_targets();
+ }
+ },
+ show_success: function(msg) {
+ this.success_msg = msg;
+ setTimeout(function(vue){
+ vue.success_msg = "";
+ }, 5000, this);
+ },
+ show_error: function(msg) {
+ this.error_msg = msg;
+ setTimeout(function(vue){
+ vue.error_msg = "";
+ }, 5000, this);
+ },
+ on_btn_compare: function() {
+ if (!this.comparing) {
+ this.comparing = true;
+ this.success_msg = "Select targets, then press compare again";
+ } else {
+ var compare_targets = $("input[name=compare_target]:checked").map((_, el) => el.value).toArray();
+ if (compare_targets.length == 0) {
+ this.show_error("Please select a few targets to compare.");
+ } else if (compare_targets.length > 3) {
+ this.show_error("We can only compare up to three targets at once.");
+ } else {
+ this.success_msg = "";
+ this.comparing = false;
+ window.open(
+ `/histogram/${this.hostname}/${compare_targets[0]}.png?` +
+ compare_targets.slice(1).map(el => `compare=${el}`).join('&')
+ );
+ }
+ }
+ },
+ show_route_for_target: function(target) {
+ this.route_target = target;
+ $('#routeModal').modal("show");
+ },
+ target_from_route_hop: function(hop) {
+ this.add_tgt_name = hop.name;
+ this.add_tgt_addr = hop.address;
+ $('#routeModal').modal("hide");
+ }
+ },
+ created: function() {
+ var self = this;
+ window.setInterval(function(vue){
+ if( new Date() - vue.last_update > 29500 ){
+ vue.update_targets();
+ }
+ }, 1000, this);
+ $(window).keydown(function(ev){
+ if (ev.ctrlKey && ev.key === "f") {
+ ev.preventDefault();
+ $("#inpsearch").focus();
+ }
+ else if (ev.key === "Escape") {
+ $("#inpsearch").blur();
+ self.search = "";
+ }
+ });
+ },
+ watch: {
+ search: function(search) {
+ localStorage.setItem("meshping_search", search);
+ this.reapply_filters();
+ },
+ targets_all: function() {
+ this.reapply_filters();
+ }
+ },
+ filters: {
+ prettyFloat: function(value) {
+ if (value === undefined || typeof value.toFixed !== 'function') {
+ return '—';
+ }
+ return value.toFixed(2);
+ }
+ }
+});
diff --git a/requirements.txt b/requirements.txt
index 80ce434..43e09a5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,3 +7,7 @@ ipwhois>=1.2.0
netifaces
netaddr
packaging
+
+
+Django
+Jinja2
\ No newline at end of file
From 9463062c41308891d8b7c2526b5ed80a15c09ca9 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Fri, 4 Apr 2025 17:34:59 +0200
Subject: [PATCH 02/19] change statistics model and fix icon rendering in
template
---
...ics_field_remove_statistics_id_and_more.py | 81 +++++++++++++
.../0003_rename_avg5m_statistics_avg15m.py | 18 +++
meshping/meshping/models.py | 16 ++-
meshping/meshping/urls.py | 1 +
meshping/meshping/views.py | 107 ++++++++++++++++--
5 files changed, 210 insertions(+), 13 deletions(-)
create mode 100644 meshping/meshping/migrations/0002_remove_statistics_field_remove_statistics_id_and_more.py
create mode 100644 meshping/meshping/migrations/0003_rename_avg5m_statistics_avg15m.py
diff --git a/meshping/meshping/migrations/0002_remove_statistics_field_remove_statistics_id_and_more.py b/meshping/meshping/migrations/0002_remove_statistics_field_remove_statistics_id_and_more.py
new file mode 100644
index 0000000..c696471
--- /dev/null
+++ b/meshping/meshping/migrations/0002_remove_statistics_field_remove_statistics_id_and_more.py
@@ -0,0 +1,81 @@
+# Generated by Django 5.1.7 on 2025-04-02 20:33
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('meshping', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='statistics',
+ name='field',
+ ),
+ migrations.RemoveField(
+ model_name='statistics',
+ name='id',
+ ),
+ migrations.RemoveField(
+ model_name='statistics',
+ name='value',
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='avg24h',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='avg5m',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='avg6h',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='last',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='lost',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='max',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='min',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='recv',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='sent',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AddField(
+ model_name='statistics',
+ name='sum',
+ field=models.FloatField(default=0.0),
+ ),
+ migrations.AlterField(
+ model_name='statistics',
+ name='target',
+ field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='meshping.target'),
+ ),
+ ]
diff --git a/meshping/meshping/migrations/0003_rename_avg5m_statistics_avg15m.py b/meshping/meshping/migrations/0003_rename_avg5m_statistics_avg15m.py
new file mode 100644
index 0000000..4aabbf7
--- /dev/null
+++ b/meshping/meshping/migrations/0003_rename_avg5m_statistics_avg15m.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.7 on 2025-04-04 14:44
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('meshping', '0002_remove_statistics_field_remove_statistics_id_and_more'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='statistics',
+ old_name='avg5m',
+ new_name='avg15m',
+ ),
+ ]
diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py
index aa094f2..28f9a01 100644
--- a/meshping/meshping/models.py
+++ b/meshping/meshping/models.py
@@ -18,12 +18,18 @@ class Histogram(models.Model):
count = models.IntegerField(default=1)
-# TODO decide on max_length, even though ignored by sqlite
-# TODO uniqueness constraint `UNIQUE (target_id, field)`
class Statistics(models.Model):
- target = models.ForeignKey(Target, on_delete=models.CASCADE)
- field = models.CharField(max_length=255)
- value = models.FloatField()
+ target = models.OneToOneField(Target, on_delete=models.CASCADE, primary_key=True)
+ sent = models.FloatField(default=0.0)
+ lost = models.FloatField(default=0.0)
+ recv = models.FloatField(default=0.0)
+ sum = models.FloatField(default=0.0)
+ last = models.FloatField(default=0.0)
+ max = models.FloatField(default=0.0)
+ min = models.FloatField(default=0.0)
+ avg15m = models.FloatField(default=0.0)
+ avg6h = models.FloatField(default=0.0)
+ avg24h = models.FloatField(default=0.0)
# TODO check if the CharField maps to SQLite TEXT + decide on max_length
diff --git a/meshping/meshping/urls.py b/meshping/meshping/urls.py
index d8757d0..c7ad991 100644
--- a/meshping/meshping/urls.py
+++ b/meshping/meshping/urls.py
@@ -4,4 +4,5 @@
urlpatterns = [
path('', views.index, name='index'),
+ path('api/targets', views.targets, name='targets'),
]
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 8c5d76b..17111e5 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -1,12 +1,15 @@
import os
import socket
-from django.http import HttpResponse
+from django.http import HttpResponse, JsonResponse
+from django.views.decorators.http import require_http_methods
from django.template import loader
from markupsafe import Markup
-from .models import Target
+from .models import Statistics, Target
+
+# route /
# TODO do not load icons from disk for every request
# TODO find a better method for finding the icons to not have an absolute path here
def index(request):
@@ -15,14 +18,102 @@ def index(request):
icons_dir = "../ui/node_modules/bootstrap-icons/icons/"
icons_dir = os.path.join(os.path.dirname(__file__), icons_dir)
print(icons_dir)
- icons = dict(
- icons={
- filename: Markup(open(os.path.join(icons_dir, filename), "r").read())
- for filename in os.listdir(icons_dir)
- }
- )
+ icons = {
+ filename: Markup(open(os.path.join(icons_dir, filename), "r").read())
+ for filename in os.listdir(icons_dir)
+ }
context = {
"Hostname": socket.gethostname(),
"icons": icons,
}
return HttpResponse(template.render(context, request))
+
+
+# route /api/targets
+@require_http_methods(["GET", "POST"])
+def targets(request):
+ if request.method == "GET":
+ targets = []
+
+ for target in Target.objects.all():
+ target_stats = Statistics.objects.filter(target=target).values()[0]
+ if not target_stats:
+ target_stats = {
+ "sent": 0, "lost": 0, "recv": 0, "sum": 0
+ }
+ succ = 0
+ loss = 0
+ if target_stats["sent"] > 0:
+ succ = target_stats["recv"] / target_stats["sent"] * 100
+ loss = (target_stats["sent"] - target_stats["recv"]) / target_stats["sent"] * 100
+ targets.append(
+ dict(
+ target_stats,
+ addr=target.addr,
+ name=target.name,
+# state=target.state,
+# error=target.error,
+ succ=succ,
+ loss=loss,
+# traceroute=target.traceroute,
+# route_loop=target.route_loop,
+ )
+ )
+
+ return JsonResponse({'targets': targets})
+ elif request.method == "POST":
+ pass
+
+#@app.route("/api/targets", methods=["GET", "POST"])
+# async def targets():
+# if request.method == "GET":
+# targets = []
+#
+# for target in mp.all_targets():
+# target_stats = target.statistics
+# succ = 0
+# loss = 0
+# if target_stats["sent"] > 0:
+# succ = target_stats["recv"] / target_stats["sent"] * 100
+# loss = (target_stats["sent"] - target_stats["recv"]) / target_stats["sent"] * 100
+# targets.append(
+# dict(
+# target_stats,
+# addr=target.addr,
+# name=target.name,
+# state=target.state,
+# error=target.error,
+# succ=succ,
+# loss=loss,
+# traceroute=target.traceroute,
+# route_loop=target.route_loop,
+# )
+# )
+#
+# return jsonify(success=True, targets=targets)
+#
+# if request.method == "POST":
+# request_json = await request.get_json()
+# if "target" not in request_json:
+# return "missing target", 400
+#
+# target = request_json["target"]
+# added = []
+#
+# if "@" not in target:
+# try:
+# addrinfo = socket.getaddrinfo(target, 0, 0, socket.SOCK_STREAM)
+# except socket.gaierror as err:
+# return jsonify(success=False, target=target, error=str(err))
+#
+# for info in addrinfo:
+# target_with_addr = "%s@%s" % (target, info[4][0])
+# mp.add_target(target_with_addr)
+# added.append(target_with_addr)
+# else:
+# mp.add_target(target)
+# added.append(target)
+#
+# return jsonify(success=True, targets=added)
+#
+# abort(400)
From 038ace0b303c2f2b4d8a3f29acce18121e3bb294 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Fri, 4 Apr 2025 18:16:15 +0200
Subject: [PATCH 03/19] adding targets via api
---
meshping/meshping/views.py | 96 +++++++++++++++-----------------------
1 file changed, 38 insertions(+), 58 deletions(-)
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 17111e5..e9b6724 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -1,7 +1,8 @@
+import json
import os
import socket
-from django.http import HttpResponse, JsonResponse
+from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.views.decorators.http import require_http_methods
from django.template import loader
from markupsafe import Markup
@@ -12,12 +13,12 @@
# route /
# TODO do not load icons from disk for every request
# TODO find a better method for finding the icons to not have an absolute path here
+# TODO make local development possible when node modules are on disk (relative path)
def index(request):
template = loader.get_template('index.html.j2')
# icons_dir = "/opt/meshping/ui/node_modules/bootstrap-icons/icons/"
icons_dir = "../ui/node_modules/bootstrap-icons/icons/"
icons_dir = os.path.join(os.path.dirname(__file__), icons_dir)
- print(icons_dir)
icons = {
filename: Markup(open(os.path.join(icons_dir, filename), "r").read())
for filename in os.listdir(icons_dir)
@@ -30,17 +31,24 @@ def index(request):
# route /api/targets
+# TODO add state to response for each target
+# TODO add error to response for each target
+# TODO add traceroute to response for each target
+# TODO add route_loop to response for each target
+# TODO do not crash when the uniqueness constraint is not met for new targets
@require_http_methods(["GET", "POST"])
def targets(request):
if request.method == "GET":
targets = []
for target in Target.objects.all():
- target_stats = Statistics.objects.filter(target=target).values()[0]
+ target_stats = Statistics.objects.filter(target=target).values()
if not target_stats:
target_stats = {
"sent": 0, "lost": 0, "recv": 0, "sum": 0
}
+ else:
+ target_stats = target_stats[0]
succ = 0
loss = 0
if target_stats["sent"] > 0:
@@ -61,59 +69,31 @@ def targets(request):
)
return JsonResponse({'targets': targets})
- elif request.method == "POST":
- pass
-#@app.route("/api/targets", methods=["GET", "POST"])
-# async def targets():
-# if request.method == "GET":
-# targets = []
-#
-# for target in mp.all_targets():
-# target_stats = target.statistics
-# succ = 0
-# loss = 0
-# if target_stats["sent"] > 0:
-# succ = target_stats["recv"] / target_stats["sent"] * 100
-# loss = (target_stats["sent"] - target_stats["recv"]) / target_stats["sent"] * 100
-# targets.append(
-# dict(
-# target_stats,
-# addr=target.addr,
-# name=target.name,
-# state=target.state,
-# error=target.error,
-# succ=succ,
-# loss=loss,
-# traceroute=target.traceroute,
-# route_loop=target.route_loop,
-# )
-# )
-#
-# return jsonify(success=True, targets=targets)
-#
-# if request.method == "POST":
-# request_json = await request.get_json()
-# if "target" not in request_json:
-# return "missing target", 400
-#
-# target = request_json["target"]
-# added = []
-#
-# if "@" not in target:
-# try:
-# addrinfo = socket.getaddrinfo(target, 0, 0, socket.SOCK_STREAM)
-# except socket.gaierror as err:
-# return jsonify(success=False, target=target, error=str(err))
-#
-# for info in addrinfo:
-# target_with_addr = "%s@%s" % (target, info[4][0])
-# mp.add_target(target_with_addr)
-# added.append(target_with_addr)
-# else:
-# mp.add_target(target)
-# added.append(target)
-#
-# return jsonify(success=True, targets=added)
-#
-# abort(400)
+ elif request.method == "POST":
+ request_json = json.loads(request.body)
+ if "target" not in request_json:
+ return HttpResponseBadRequest("missing target")
+ target = request_json["target"]
+ added = []
+ if "@" not in target:
+ try:
+ addrinfo = socket.getaddrinfo(target, 0, 0, socket.SOCK_STREAM)
+ except socket.gaierror as err:
+ return JsonResponse({
+ 'success': False,
+ 'target': target,
+ 'error': str(err),
+ })
+ for info in addrinfo:
+ addr = info[4][0]
+ Target(name=target, addr=addr).save()
+ added.append(f"{target}@{addr}")
+ else:
+ tname, addr = target.split('@')
+ Target(name=tname, addr=addr).save()
+ added.append(target)
+ return JsonResponse({
+ 'success': True,
+ 'targets': added,
+ })
From ac780742e27960279dc97e49405636b72fadad99 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Fri, 4 Apr 2025 19:14:11 +0200
Subject: [PATCH 04/19] stubs for all api endpoints
---
meshping/meshping/urls.py | 9 +++++++++
meshping/meshping/views.py | 37 ++++++++++++++++++++++++++++++++++++-
2 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/meshping/meshping/urls.py b/meshping/meshping/urls.py
index c7ad991..a5772ca 100644
--- a/meshping/meshping/urls.py
+++ b/meshping/meshping/urls.py
@@ -2,7 +2,16 @@
from . import views
+# TODO decide about and deal with caching on /ui resources (django staticfiles)
urlpatterns = [
path('', views.index, name='index'),
+ path('histogram//.png', views.histogram, name='histogram'),
+ path('metrics', views.metrics, name='metrics'),
+ path('network.svg', views.network, name='network'),
+ path('peer', views.peer, name='peer'),
+
+ path('api/resolve/', views.resolve, name='resolve'),
+ path('api/stats', views.stats, name='stats'),
path('api/targets', views.targets, name='targets'),
+ path('api/targets/', views.edit_target, name='target'),
]
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index e9b6724..96544a4 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -2,7 +2,7 @@
import os
import socket
-from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
+from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseServerError, JsonResponse
from django.views.decorators.http import require_http_methods
from django.template import loader
from markupsafe import Markup
@@ -30,6 +30,36 @@ def index(request):
return HttpResponse(template.render(context, request))
+# route /histogram//.png
+def histogram(request, **kwargs):
+ return HttpResponseServerError('not implemented')
+
+
+# route /metrics
+def metrics(request):
+ return HttpResponseServerError('not implemented')
+
+
+# route /network.svg
+def network(request):
+ return HttpResponseServerError('not implemented')
+
+
+# route /peer
+def peer(request):
+ return HttpResponseServerError('not implemented')
+
+
+# route /api/resolve/
+def resolve(request, **kwargs):
+ return HttpResponseServerError('not implemented')
+
+
+# route /api/stats
+def stats(request):
+ return HttpResponseServerError('not implemented')
+
+
# route /api/targets
# TODO add state to response for each target
# TODO add error to response for each target
@@ -97,3 +127,8 @@ def targets(request):
'success': True,
'targets': added,
})
+
+
+# route /api/targets/
+def edit_target(request, **kwargs):
+ return HttpResponseServerError('not implemented')
From 1ee340a47bab329a52c2b59bd34daf3f9ac9050c Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Fri, 4 Apr 2025 19:38:33 +0200
Subject: [PATCH 05/19] format code with the black formatter
---
meshping/manage.py | 4 +-
meshping/meshping/apps.py | 4 +-
meshping/meshping/asgi.py | 11 +---
meshping/meshping/jinja2.py | 21 ++++---
meshping/meshping/models.py | 22 +++----
meshping/meshping/settings.py | 78 +++++++++++++------------
meshping/meshping/urls.py | 19 +++---
meshping/meshping/views.py | 105 +++++++++++++++++++---------------
meshping/meshping/wsgi.py | 11 +---
pyproject.toml | 2 +
10 files changed, 136 insertions(+), 141 deletions(-)
create mode 100644 pyproject.toml
diff --git a/meshping/manage.py b/meshping/manage.py
index 324b250..e96c666 100755
--- a/meshping/manage.py
+++ b/meshping/manage.py
@@ -6,7 +6,7 @@
def main():
"""Run administrative tasks."""
- os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'meshping.settings')
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meshping.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -18,5 +18,5 @@ def main():
execute_from_command_line(sys.argv)
-if __name__ == '__main__':
+if __name__ == "__main__":
main()
diff --git a/meshping/meshping/apps.py b/meshping/meshping/apps.py
index 6b2b4a8..16c5a34 100644
--- a/meshping/meshping/apps.py
+++ b/meshping/meshping/apps.py
@@ -2,8 +2,8 @@
class MeshpingConfig(AppConfig):
- default_auto_field = 'django.db.models.BigAutoField'
- name = 'meshping'
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "meshping"
def ready(self):
# TODO start background threads for ping, traceroute, peers
diff --git a/meshping/meshping/asgi.py b/meshping/meshping/asgi.py
index 1afae58..4b48dcb 100644
--- a/meshping/meshping/asgi.py
+++ b/meshping/meshping/asgi.py
@@ -1,16 +1,7 @@
-"""
-ASGI config for meshping project.
-
-It exposes the ASGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
-"""
-
import os
from django.core.asgi import get_asgi_application
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'meshping.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meshping.settings")
application = get_asgi_application()
diff --git a/meshping/meshping/jinja2.py b/meshping/meshping/jinja2.py
index f575cba..aa087bd 100644
--- a/meshping/meshping/jinja2.py
+++ b/meshping/meshping/jinja2.py
@@ -1,15 +1,14 @@
-from jinja2 import Environment
-from django.urls import reverse
+from jinja2 import Environment
+from django.urls import reverse
from django.templatetags.static import static
-def environment(**options):
- env = Environment(
- variable_start_string='{[',
- variable_end_string=']}',
- **options)
- env.globals.update({
- "static": static,
- "url": reverse
- })
+def environment(**options):
+ env = Environment(variable_start_string="{[", variable_end_string="]}", **options)
+ env.globals.update(
+ {
+ "static": static,
+ "url": reverse,
+ }
+ )
return env
diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py
index 28f9a01..5647881 100644
--- a/meshping/meshping/models.py
+++ b/meshping/meshping/models.py
@@ -7,7 +7,7 @@ class Target(models.Model):
name = models.CharField(max_length=255)
class Meta:
- unique_together = ('addr', 'name')
+ unique_together = ("addr", "name")
# TODO uniqueness constraint `UNIQUE (target_id, timestamp, bucket)`
@@ -20,16 +20,16 @@ class Histogram(models.Model):
class Statistics(models.Model):
target = models.OneToOneField(Target, on_delete=models.CASCADE, primary_key=True)
- sent = models.FloatField(default=0.0)
- lost = models.FloatField(default=0.0)
- recv = models.FloatField(default=0.0)
- sum = models.FloatField(default=0.0)
- last = models.FloatField(default=0.0)
- max = models.FloatField(default=0.0)
- min = models.FloatField(default=0.0)
- avg15m = models.FloatField(default=0.0)
- avg6h = models.FloatField(default=0.0)
- avg24h = models.FloatField(default=0.0)
+ sent = models.FloatField(default=0.0)
+ lost = models.FloatField(default=0.0)
+ recv = models.FloatField(default=0.0)
+ sum = models.FloatField(default=0.0)
+ last = models.FloatField(default=0.0)
+ max = models.FloatField(default=0.0)
+ min = models.FloatField(default=0.0)
+ avg15m = models.FloatField(default=0.0)
+ avg6h = models.FloatField(default=0.0)
+ avg24h = models.FloatField(default=0.0)
# TODO check if the CharField maps to SQLite TEXT + decide on max_length
diff --git a/meshping/meshping/settings.py b/meshping/meshping/settings.py
index be9ec64..8e1b9a1 100644
--- a/meshping/meshping/settings.py
+++ b/meshping/meshping/settings.py
@@ -4,21 +4,21 @@
BASE_DIR = Path(__file__).resolve().parent.parent
-SECRET_KEY = 'django-insecure-2svcr!j9%^_(^)$%)vvt1j&0$w)&_l&oxacnb1mkurd4f--xmg'
+SECRET_KEY = "django-insecure-2svcr!j9%^_(^)$%)vvt1j&0$w)&_l&oxacnb1mkurd4f--xmg"
DEBUG = True
-ALLOWED_HOSTS = ['*']
+ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
-# 'django.contrib.admin',
-# 'django.contrib.auth',
-# 'django.contrib.contenttypes',
-# 'django.contrib.sessions',
-# 'django.contrib.messages',
- 'django.contrib.staticfiles',
- 'meshping',
+ # 'django.contrib.admin',
+ # 'django.contrib.auth',
+ # 'django.contrib.contenttypes',
+ # 'django.contrib.sessions',
+ # 'django.contrib.messages',
+ "django.contrib.staticfiles",
+ "meshping",
]
-#MIDDLEWARE = [
+# MIDDLEWARE = [
# 'django.middleware.security.SecurityMiddleware',
# 'django.contrib.sessions.middleware.SessionMiddleware',
# 'django.middleware.common.CommonMiddleware',
@@ -26,48 +26,50 @@
# 'django.contrib.auth.middleware.AuthenticationMiddleware',
# 'django.contrib.messages.middleware.MessageMiddleware',
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
-#]
+# ]
-ROOT_URLCONF = 'meshping.urls'
+ROOT_URLCONF = "meshping.urls"
# TODO the APP_DIRS seem to not work as I imagined for the jinja2 backend
TEMPLATES = [
{
"BACKEND": "django.template.backends.jinja2.Jinja2",
- "DIRS": [os.path.join(BASE_DIR, 'meshping/templates'),],
+ "DIRS": [
+ os.path.join(BASE_DIR, "meshping/templates"),
+ ],
"APP_DIRS": False,
"OPTIONS": {
"environment": "meshping.jinja2.environment",
},
},
-# {
-# 'BACKEND': 'django.template.backends.django.DjangoTemplates',
-# 'DIRS': [],
-# 'APP_DIRS': True,
-# 'OPTIONS': {
-# 'context_processors': [
-# 'django.template.context_processors.debug',
-# 'django.template.context_processors.request',
-## 'django.contrib.auth.context_processors.auth',
-## 'django.contrib.messages.context_processors.messages',
-# ],
-# },
-# },
+ # {
+ # 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ # 'DIRS': [],
+ # 'APP_DIRS': True,
+ # 'OPTIONS': {
+ # 'context_processors': [
+ # 'django.template.context_processors.debug',
+ # 'django.template.context_processors.request',
+ ## 'django.contrib.auth.context_processors.auth',
+ ## 'django.contrib.messages.context_processors.messages',
+ # ],
+ # },
+ # },
]
-#WSGI_APPLICATION = 'meshping.wsgi.application'
+# WSGI_APPLICATION = 'meshping.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': BASE_DIR / 'db' / 'db.sqlite3',
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": BASE_DIR / "db" / "db.sqlite3",
}
}
-#AUTH_PASSWORD_VALIDATORS = [
+# AUTH_PASSWORD_VALIDATORS = [
# {
# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
# },
@@ -80,16 +82,16 @@
# {
# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
# },
-#]
+# ]
-#LANGUAGE_CODE = 'en-us'
-#TIME_ZONE = 'UTC'
-#USE_I18N = True
-#USE_TZ = True
+# LANGUAGE_CODE = 'en-us'
+# TIME_ZONE = 'UTC'
+# USE_I18N = True
+# USE_TZ = True
-STATIC_URL = 'ui/'
+STATIC_URL = "ui/"
STATICFILES_DIRS = [
BASE_DIR / "ui",
]
-DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
diff --git a/meshping/meshping/urls.py b/meshping/meshping/urls.py
index a5772ca..142e453 100644
--- a/meshping/meshping/urls.py
+++ b/meshping/meshping/urls.py
@@ -4,14 +4,13 @@
# TODO decide about and deal with caching on /ui resources (django staticfiles)
urlpatterns = [
- path('', views.index, name='index'),
- path('histogram//.png', views.histogram, name='histogram'),
- path('metrics', views.metrics, name='metrics'),
- path('network.svg', views.network, name='network'),
- path('peer', views.peer, name='peer'),
-
- path('api/resolve/', views.resolve, name='resolve'),
- path('api/stats', views.stats, name='stats'),
- path('api/targets', views.targets, name='targets'),
- path('api/targets/', views.edit_target, name='target'),
+ path("", views.index, name="index"),
+ path("histogram//.png", views.histogram, name="histogram"),
+ path("metrics", views.metrics, name="metrics"),
+ path("network.svg", views.network, name="network"),
+ path("peer", views.peer, name="peer"),
+ path("api/resolve/", views.resolve, name="resolve"),
+ path("api/stats", views.stats, name="stats"),
+ path("api/targets", views.targets, name="targets"),
+ path("api/targets/", views.edit_target, name="target"),
]
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 96544a4..47fd58d 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -2,7 +2,12 @@
import os
import socket
-from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseServerError, JsonResponse
+from django.http import (
+ HttpResponse,
+ HttpResponseBadRequest,
+ HttpResponseServerError,
+ JsonResponse,
+)
from django.views.decorators.http import require_http_methods
from django.template import loader
from markupsafe import Markup
@@ -15,7 +20,7 @@
# TODO find a better method for finding the icons to not have an absolute path here
# TODO make local development possible when node modules are on disk (relative path)
def index(request):
- template = loader.get_template('index.html.j2')
+ template = loader.get_template("index.html.j2")
# icons_dir = "/opt/meshping/ui/node_modules/bootstrap-icons/icons/"
icons_dir = "../ui/node_modules/bootstrap-icons/icons/"
icons_dir = os.path.join(os.path.dirname(__file__), icons_dir)
@@ -32,32 +37,32 @@ def index(request):
# route /histogram//.png
def histogram(request, **kwargs):
- return HttpResponseServerError('not implemented')
+ return HttpResponseServerError("not implemented")
# route /metrics
def metrics(request):
- return HttpResponseServerError('not implemented')
+ return HttpResponseServerError("not implemented")
# route /network.svg
def network(request):
- return HttpResponseServerError('not implemented')
+ return HttpResponseServerError("not implemented")
# route /peer
def peer(request):
- return HttpResponseServerError('not implemented')
+ return HttpResponseServerError("not implemented")
# route /api/resolve/
def resolve(request, **kwargs):
- return HttpResponseServerError('not implemented')
+ return HttpResponseServerError("not implemented")
# route /api/stats
def stats(request):
- return HttpResponseServerError('not implemented')
+ return HttpResponseServerError("not implemented")
# route /api/targets
@@ -69,36 +74,38 @@ def stats(request):
@require_http_methods(["GET", "POST"])
def targets(request):
if request.method == "GET":
- targets = []
-
- for target in Target.objects.all():
- target_stats = Statistics.objects.filter(target=target).values()
- if not target_stats:
- target_stats = {
- "sent": 0, "lost": 0, "recv": 0, "sum": 0
- }
- else:
- target_stats = target_stats[0]
- succ = 0
- loss = 0
- if target_stats["sent"] > 0:
- succ = target_stats["recv"] / target_stats["sent"] * 100
- loss = (target_stats["sent"] - target_stats["recv"]) / target_stats["sent"] * 100
- targets.append(
- dict(
- target_stats,
- addr=target.addr,
- name=target.name,
-# state=target.state,
-# error=target.error,
- succ=succ,
- loss=loss,
-# traceroute=target.traceroute,
-# route_loop=target.route_loop,
- )
+ targets = []
+
+ for target in Target.objects.all():
+ target_stats = Statistics.objects.filter(target=target).values()
+ if not target_stats:
+ target_stats = {"sent": 0, "lost": 0, "recv": 0, "sum": 0}
+ else:
+ target_stats = target_stats[0]
+ succ = 0
+ loss = 0
+ if target_stats["sent"] > 0:
+ succ = target_stats["recv"] / target_stats["sent"] * 100
+ loss = (
+ (target_stats["sent"] - target_stats["recv"])
+ / target_stats["sent"]
+ * 100
+ )
+ targets.append(
+ dict(
+ target_stats,
+ addr=target.addr,
+ name=target.name,
+ # state=target.state,
+ # error=target.error,
+ succ=succ,
+ loss=loss,
+ # traceroute=target.traceroute,
+ # route_loop=target.route_loop,
)
+ )
- return JsonResponse({'targets': targets})
+ return JsonResponse({"targets": targets})
elif request.method == "POST":
request_json = json.loads(request.body)
@@ -110,25 +117,29 @@ def targets(request):
try:
addrinfo = socket.getaddrinfo(target, 0, 0, socket.SOCK_STREAM)
except socket.gaierror as err:
- return JsonResponse({
- 'success': False,
- 'target': target,
- 'error': str(err),
- })
+ return JsonResponse(
+ {
+ "success": False,
+ "target": target,
+ "error": str(err),
+ }
+ )
for info in addrinfo:
addr = info[4][0]
Target(name=target, addr=addr).save()
added.append(f"{target}@{addr}")
else:
- tname, addr = target.split('@')
+ tname, addr = target.split("@")
Target(name=tname, addr=addr).save()
added.append(target)
- return JsonResponse({
- 'success': True,
- 'targets': added,
- })
+ return JsonResponse(
+ {
+ "success": True,
+ "targets": added,
+ }
+ )
# route /api/targets/
def edit_target(request, **kwargs):
- return HttpResponseServerError('not implemented')
+ return HttpResponseServerError("not implemented")
diff --git a/meshping/meshping/wsgi.py b/meshping/meshping/wsgi.py
index c227ee0..212c147 100644
--- a/meshping/meshping/wsgi.py
+++ b/meshping/meshping/wsgi.py
@@ -1,16 +1,7 @@
-"""
-WSGI config for meshping project.
-
-It exposes the WSGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
-"""
-
import os
from django.core.wsgi import get_wsgi_application
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'meshping.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meshping.settings")
application = get_wsgi_application()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..9b5d13e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,2 @@
+[tool.black]
+exclude = 'meshping/.[^/]+/migrations'
From d4695b7895323a467066d93318781f648d2d3632 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Fri, 4 Apr 2025 20:00:05 +0200
Subject: [PATCH 06/19] make pylint happy
---
.pylintrc | 3 ++-
meshping/meshping/urls.py | 2 +-
meshping/meshping/views.py | 14 +++++++++++---
3 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/.pylintrc b/.pylintrc
index d3d930b..29e283f 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -1,11 +1,12 @@
[MASTER]
extension-pkg-whitelist = netifaces
+ignore-paths = meshping/.[^/]+/migrations
[FORMAT]
max-line-length=120
[MESSAGES CONTROL]
-disable=E1101,E1102,E1103,C0111,C0103,W0613,W0108,W0212,R0903
+disable=E1101,E1102,E1103,C0111,C0103,W0613,W0108,W0212,R0903,W0511
[DESIGN]
max-public-methods=100
diff --git a/meshping/meshping/urls.py b/meshping/meshping/urls.py
index 142e453..6e0b69d 100644
--- a/meshping/meshping/urls.py
+++ b/meshping/meshping/urls.py
@@ -11,6 +11,6 @@
path("peer", views.peer, name="peer"),
path("api/resolve/", views.resolve, name="resolve"),
path("api/stats", views.stats, name="stats"),
- path("api/targets", views.targets, name="targets"),
+ path("api/targets", views.targets_endpoint, name="targets"),
path("api/targets/", views.edit_target, name="target"),
]
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 47fd58d..a65ab8f 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -20,12 +20,16 @@
# TODO find a better method for finding the icons to not have an absolute path here
# TODO make local development possible when node modules are on disk (relative path)
def index(request):
+ def read_svg_file(icons_dir, filename):
+ with open(os.path.join(icons_dir, filename), "r", encoding="utf-8") as f:
+ return Markup(f.read())
+
template = loader.get_template("index.html.j2")
# icons_dir = "/opt/meshping/ui/node_modules/bootstrap-icons/icons/"
icons_dir = "../ui/node_modules/bootstrap-icons/icons/"
icons_dir = os.path.join(os.path.dirname(__file__), icons_dir)
icons = {
- filename: Markup(open(os.path.join(icons_dir, filename), "r").read())
+ filename: read_svg_file(icons_dir, filename)
for filename in os.listdir(icons_dir)
}
context = {
@@ -66,13 +70,17 @@ def stats(request):
# route /api/targets
+#
+# django ensures a valid http request method, so we do not need a return value
+# pylint: disable=inconsistent-return-statements
+#
# TODO add state to response for each target
# TODO add error to response for each target
# TODO add traceroute to response for each target
# TODO add route_loop to response for each target
# TODO do not crash when the uniqueness constraint is not met for new targets
@require_http_methods(["GET", "POST"])
-def targets(request):
+def targets_endpoint(request):
if request.method == "GET":
targets = []
@@ -107,7 +115,7 @@ def targets(request):
return JsonResponse({"targets": targets})
- elif request.method == "POST":
+ if request.method == "POST":
request_json = json.loads(request.body)
if "target" not in request_json:
return HttpResponseBadRequest("missing target")
From ddc322be35c6d3bd4de265247021a451c4d510cc Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Fri, 4 Apr 2025 20:08:21 +0200
Subject: [PATCH 07/19] housekeeping
---
meshping/meshping/wsgi.py | 7 -------
requirements.txt | 6 +++---
2 files changed, 3 insertions(+), 10 deletions(-)
delete mode 100644 meshping/meshping/wsgi.py
diff --git a/meshping/meshping/wsgi.py b/meshping/meshping/wsgi.py
deleted file mode 100644
index 212c147..0000000
--- a/meshping/meshping/wsgi.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import os
-
-from django.core.wsgi import get_wsgi_application
-
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meshping.settings")
-
-application = get_wsgi_application()
diff --git a/requirements.txt b/requirements.txt
index 43e09a5..10f339d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,6 +8,6 @@ netifaces
netaddr
packaging
-
-Django
-Jinja2
\ No newline at end of file
+black~=25.1.0
+Django~=5.1.7
+Jinja2~=3.1.6
\ No newline at end of file
From b804ad24b14f090181d3eefdc501098ed1e46882 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sat, 5 Apr 2025 20:03:34 +0200
Subject: [PATCH 08/19] ping thread and fixes for /api/targets
---
.gitignore | 1 +
.pylintrc | 2 +-
meshping/meshping/apps.py | 13 +-
meshping/meshping/meshping/__init__.py | 0
meshping/meshping/meshping/meshping_config.py | 8 ++
meshping/meshping/meshping/ping_thread.py | 130 ++++++++++++++++++
...move_meta_id_remove_meta_value_and_more.py | 65 +++++++++
.../0005_meta_error_alter_meta_state.py | 35 +++++
...6_alter_meta_lkgt_alter_meta_traceroute.py | 23 ++++
meshping/meshping/models.py | 24 +++-
meshping/meshping/settings.py | 53 -------
meshping/meshping/views.py | 21 ++-
12 files changed, 309 insertions(+), 66 deletions(-)
create mode 100644 meshping/meshping/meshping/__init__.py
create mode 100644 meshping/meshping/meshping/meshping_config.py
create mode 100644 meshping/meshping/meshping/ping_thread.py
create mode 100644 meshping/meshping/migrations/0004_remove_meta_field_remove_meta_id_remove_meta_value_and_more.py
create mode 100644 meshping/meshping/migrations/0005_meta_error_alter_meta_state.py
create mode 100644 meshping/meshping/migrations/0006_alter_meta_lkgt_alter_meta_traceroute.py
diff --git a/.gitignore b/.gitignore
index 0f04a06..39c250f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
python-ping/
+__pycache__
*.pyc
oping.c
diff --git a/.pylintrc b/.pylintrc
index 29e283f..e0de6a4 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -3,7 +3,7 @@ extension-pkg-whitelist = netifaces
ignore-paths = meshping/.[^/]+/migrations
[FORMAT]
-max-line-length=120
+max-line-length=88
[MESSAGES CONTROL]
disable=E1101,E1102,E1103,C0111,C0103,W0613,W0108,W0212,R0903,W0511
diff --git a/meshping/meshping/apps.py b/meshping/meshping/apps.py
index 16c5a34..e6babc0 100644
--- a/meshping/meshping/apps.py
+++ b/meshping/meshping/apps.py
@@ -1,10 +1,19 @@
from django.apps import AppConfig
+from .meshping.meshping_config import MeshpingConfig as MPCOnfig
class MeshpingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "meshping"
+ # TODO decide for long-term layout: standard threads, async, scheduling library
+ # TODO start background threads for traceroute, peers
+ # TODO create background task for db housekeeping (prune_histograms)
def ready(self):
- # TODO start background threads for ping, traceroute, peers
- pass
+ # delayed import, otherwise we will get AppRegistryNotReady
+ # pylint: disable=import-outside-toplevel
+ from .meshping.ping_thread import PingThread
+
+ mp_config = MPCOnfig()
+ ping_thread = PingThread(mp_config=mp_config, daemon=True)
+ ping_thread.start()
diff --git a/meshping/meshping/meshping/__init__.py b/meshping/meshping/meshping/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/meshping/meshping/meshping/meshping_config.py b/meshping/meshping/meshping/meshping_config.py
new file mode 100644
index 0000000..84498d2
--- /dev/null
+++ b/meshping/meshping/meshping/meshping_config.py
@@ -0,0 +1,8 @@
+# TODO add implementation for values from environment
+# TODO decide about option for config file, and automatic combination with docker
+# environment variables
+class MeshpingConfig:
+ def __init__(self):
+ self.ping_timeout = 1
+ self.ping_interval = 1
+ self.histogram_period = 1
diff --git a/meshping/meshping/meshping/ping_thread.py b/meshping/meshping/meshping/ping_thread.py
new file mode 100644
index 0000000..22301e9
--- /dev/null
+++ b/meshping/meshping/meshping/ping_thread.py
@@ -0,0 +1,130 @@
+import time
+import math
+from threading import Thread
+
+# pylint cannot read the definitions in the shared object
+# pylint: disable=no-name-in-module
+from oping import PingObj, PingError
+from ..models import Histogram, Target, Statistics, Meta, TargetState
+
+
+INTERVAL = 30
+FAC_15m = math.exp(-INTERVAL / (15 * 60.0))
+FAC_6h = math.exp(-INTERVAL / (6 * 60 * 60.0))
+FAC_24h = math.exp(-INTERVAL / (24 * 60 * 60.0))
+
+
+class PingThread(Thread):
+ def __init__(self, mp_config, *args, **kwargs):
+ self.mp_config = mp_config
+ super().__init__(*args, **kwargs)
+
+ @staticmethod
+ def exp_avg(current_avg, add_value, factor):
+ if current_avg is None:
+ return add_value
+ return (current_avg * factor) + (add_value * (1 - factor))
+
+ def process_ping_result(self, timestamp, hostinfo):
+ target = Target.objects.filter(addr=hostinfo["addr"]).first()
+ # TODO proper error handling instead of assert
+ assert target is not None
+ target_stats, _created = Statistics.objects.get_or_create(target=target)
+ target_meta, _created = Meta.objects.get_or_create(target=target)
+
+ target_stats.sent += 1
+
+ if hostinfo["latency"] != -1:
+ target_meta.state = TargetState.UP
+ target_stats.recv += 1
+ target_stats.last = hostinfo["latency"]
+ target_stats.sum += target_stats.last
+ target_stats.max = max(target_stats.max, target_stats.last)
+ target_stats.min = min(target_stats.min, target_stats.last)
+ target_stats.avg15m = self.exp_avg(
+ target_stats.avg15m, target_stats.last, FAC_15m
+ )
+ target_stats.avg6h = self.exp_avg(
+ target_stats.avg6h, target_stats.last, FAC_6h
+ )
+ target_stats.avg24h = self.exp_avg(
+ target_stats.avg24h, target_stats.last, FAC_24h
+ )
+
+ bucket, _created = Histogram.objects.get_or_create(
+ target=target,
+ timestamp=timestamp // 3600 * 3600,
+ bucket=int(math.log(hostinfo["latency"], 2) * 10),
+ )
+ bucket.count += 1
+ bucket.save()
+
+ else:
+ target_meta.state = TargetState.DOWN
+ target_stats.lost += 1
+
+ target_stats.save()
+ target_meta.save()
+
+ def run(self):
+ pingobj = PingObj()
+ pingobj.set_timeout(self.mp_config.ping_timeout)
+
+ next_ping = time.time() + 0.1
+
+ current_targets = set()
+
+ while True:
+ now = time.time()
+ next_ping = now + self.mp_config.ping_interval
+
+ # Run DB housekeeping
+ # TODO has nothing to do with pings, find a better place (probably new
+ # housekeeping thread)
+ Histogram.objects.filter(
+ timestamp__lt=(now - self.mp_config.histogram_period)
+ ).delete()
+
+ unseen_targets = current_targets.copy()
+ for target in Target.objects.all():
+ if target.addr not in current_targets:
+ current_targets.add(target.addr)
+ try:
+ pingobj.add_host(target.addr.encode("utf-8"))
+ except PingError as err:
+ # TODO make this safe, do not just assume the object exists
+ target_meta = Meta.objects.filter(target=target)[0]
+ target_meta.state = TargetState.ERROR
+ target_meta.error = err.args[0].decode("utf-8")
+ target_meta.save()
+ if target.addr in unseen_targets:
+ unseen_targets.remove(target.addr)
+
+ for target_addr in unseen_targets:
+ current_targets.remove(target_addr)
+ try:
+ pingobj.remove_host(target_addr.encode("utf-8"))
+ except PingError:
+ # Host probably not there anyway
+ pass
+
+ # If we don't have any targets, we're done for now -- just sleep
+ if not current_targets:
+ time.sleep(max(0, next_ping - time.time()))
+ continue
+
+ # We do have targets, so first, let's ping them
+ pingobj.send()
+
+ for hostinfo in pingobj.get_hosts():
+ hostinfo["addr"] = hostinfo["addr"].decode("utf-8")
+
+ try:
+ self.process_ping_result(now, hostinfo)
+ except LookupError:
+ # ping takes a while. it's possible that while we were busy, this
+ # target has been deleted from the DB. If so, forget about it.
+ if hostinfo["addr"] in current_targets:
+ current_targets.remove(hostinfo["addr"])
+
+ time.sleep(max(0, next_ping - time.time()))
diff --git a/meshping/meshping/migrations/0004_remove_meta_field_remove_meta_id_remove_meta_value_and_more.py b/meshping/meshping/migrations/0004_remove_meta_field_remove_meta_id_remove_meta_value_and_more.py
new file mode 100644
index 0000000..6f9c26f
--- /dev/null
+++ b/meshping/meshping/migrations/0004_remove_meta_field_remove_meta_id_remove_meta_value_and_more.py
@@ -0,0 +1,65 @@
+# Generated by Django 5.1.7 on 2025-04-05 15:42
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("meshping", "0003_rename_avg5m_statistics_avg15m"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="meta",
+ name="field",
+ ),
+ migrations.RemoveField(
+ model_name="meta",
+ name="id",
+ ),
+ migrations.RemoveField(
+ model_name="meta",
+ name="value",
+ ),
+ migrations.AddField(
+ model_name="meta",
+ name="lkgt",
+ field=models.JSONField(default=[], max_length=2048),
+ ),
+ migrations.AddField(
+ model_name="meta",
+ name="route_loop",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name="meta",
+ name="state",
+ field=models.CharField(
+ choices=[("up", "Up"), ("down", "Down"), ("unknown", "Unknown")],
+ default="down",
+ max_length=10,
+ ),
+ ),
+ migrations.AddField(
+ model_name="meta",
+ name="traceroute",
+ field=models.JSONField(default=[], max_length=2048),
+ ),
+ migrations.AlterField(
+ model_name="meta",
+ name="target",
+ field=models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ primary_key=True,
+ serialize=False,
+ to="meshping.target",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="statistics",
+ name="min",
+ field=models.FloatField(default=float("inf")),
+ ),
+ ]
diff --git a/meshping/meshping/migrations/0005_meta_error_alter_meta_state.py b/meshping/meshping/migrations/0005_meta_error_alter_meta_state.py
new file mode 100644
index 0000000..ab4d553
--- /dev/null
+++ b/meshping/meshping/migrations/0005_meta_error_alter_meta_state.py
@@ -0,0 +1,35 @@
+# Generated by Django 5.1.7 on 2025-04-05 16:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ (
+ "meshping",
+ "0004_remove_meta_field_remove_meta_id_remove_meta_value_and_more",
+ ),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="meta",
+ name="error",
+ field=models.CharField(default=None, max_length=255, null=True),
+ ),
+ migrations.AlterField(
+ model_name="meta",
+ name="state",
+ field=models.CharField(
+ choices=[
+ ("up", "Up"),
+ ("down", "Down"),
+ ("unknown", "Unknown"),
+ ("error", "Error"),
+ ],
+ default="down",
+ max_length=10,
+ ),
+ ),
+ ]
diff --git a/meshping/meshping/migrations/0006_alter_meta_lkgt_alter_meta_traceroute.py b/meshping/meshping/migrations/0006_alter_meta_lkgt_alter_meta_traceroute.py
new file mode 100644
index 0000000..8e6dd61
--- /dev/null
+++ b/meshping/meshping/migrations/0006_alter_meta_lkgt_alter_meta_traceroute.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.1.7 on 2025-04-05 16:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("meshping", "0005_meta_error_alter_meta_state"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="meta",
+ name="lkgt",
+ field=models.JSONField(default=list, max_length=2048),
+ ),
+ migrations.AlterField(
+ model_name="meta",
+ name="traceroute",
+ field=models.JSONField(default=list, max_length=2048),
+ ),
+ ]
diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py
index 5647881..de6792b 100644
--- a/meshping/meshping/models.py
+++ b/meshping/meshping/models.py
@@ -26,15 +26,29 @@ class Statistics(models.Model):
sum = models.FloatField(default=0.0)
last = models.FloatField(default=0.0)
max = models.FloatField(default=0.0)
- min = models.FloatField(default=0.0)
+ min = models.FloatField(default=float("inf"))
avg15m = models.FloatField(default=0.0)
avg6h = models.FloatField(default=0.0)
avg24h = models.FloatField(default=0.0)
-# TODO check if the CharField maps to SQLite TEXT + decide on max_length
+# pylint: disable=too-many-ancestors
+class TargetState(models.TextChoices):
+ UP = "up"
+ DOWN = "down"
+ UNKNOWN = "unknown"
+ ERROR = "error"
+
+
+# TODO decide on max_length, even though ignored by sqlite
# TODO uniqueness constraint `UNIQUE (target_id, field)`
class Meta(models.Model):
- target = models.ForeignKey(Target, on_delete=models.CASCADE)
- field = models.CharField(max_length=255)
- value = models.CharField(max_length=255)
+ target = models.OneToOneField(Target, on_delete=models.CASCADE, primary_key=True)
+ state = models.CharField(
+ max_length=10, choices=TargetState.choices, default=TargetState.DOWN
+ )
+ route_loop = models.BooleanField(default=False)
+ traceroute = models.JSONField(max_length=2048, default=list)
+ # lkgt = last known good traceroute
+ lkgt = models.JSONField(max_length=2048, default=list)
+ error = models.CharField(max_length=255, null=True, default=None)
diff --git a/meshping/meshping/settings.py b/meshping/meshping/settings.py
index 8e1b9a1..dafc6cb 100644
--- a/meshping/meshping/settings.py
+++ b/meshping/meshping/settings.py
@@ -9,25 +9,10 @@
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
- # 'django.contrib.admin',
- # 'django.contrib.auth',
- # 'django.contrib.contenttypes',
- # 'django.contrib.sessions',
- # 'django.contrib.messages',
"django.contrib.staticfiles",
"meshping",
]
-# MIDDLEWARE = [
-# 'django.middleware.security.SecurityMiddleware',
-# 'django.contrib.sessions.middleware.SessionMiddleware',
-# 'django.middleware.common.CommonMiddleware',
-# 'django.middleware.csrf.CsrfViewMiddleware',
-# 'django.contrib.auth.middleware.AuthenticationMiddleware',
-# 'django.contrib.messages.middleware.MessageMiddleware',
-# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
-# ]
-
ROOT_URLCONF = "meshping.urls"
# TODO the APP_DIRS seem to not work as I imagined for the jinja2 backend
@@ -42,25 +27,7 @@
"environment": "meshping.jinja2.environment",
},
},
- # {
- # 'BACKEND': 'django.template.backends.django.DjangoTemplates',
- # 'DIRS': [],
- # 'APP_DIRS': True,
- # 'OPTIONS': {
- # 'context_processors': [
- # 'django.template.context_processors.debug',
- # 'django.template.context_processors.request',
- ## 'django.contrib.auth.context_processors.auth',
- ## 'django.contrib.messages.context_processors.messages',
- # ],
- # },
- # },
]
-# WSGI_APPLICATION = 'meshping.wsgi.application'
-
-
-# Database
-# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
DATABASES = {
"default": {
@@ -69,26 +36,6 @@
}
}
-# AUTH_PASSWORD_VALIDATORS = [
-# {
-# 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
-# },
-# {
-# 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
-# },
-# {
-# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
-# },
-# {
-# 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
-# },
-# ]
-
-# LANGUAGE_CODE = 'en-us'
-# TIME_ZONE = 'UTC'
-# USE_I18N = True
-# USE_TZ = True
-
STATIC_URL = "ui/"
STATICFILES_DIRS = [
BASE_DIR / "ui",
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index a65ab8f..6df648e 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -12,7 +12,10 @@
from django.template import loader
from markupsafe import Markup
-from .models import Statistics, Target
+from .models import Statistics, Target, Meta
+
+
+# TODO remove business logic in this file
# route /
@@ -99,17 +102,25 @@ def targets_endpoint(request):
/ target_stats["sent"]
* 100
)
+
+ # the browser cannot deserialize JSON with Infinity, but this value is
+ # very comfortable for comparisons, we want to keep it
+ if target_stats["min"] == float("inf"):
+ target_stats["min"] = 0
+
+ target_meta, _created = Meta.objects.get_or_create(target=target)
+
targets.append(
dict(
target_stats,
addr=target.addr,
name=target.name,
- # state=target.state,
- # error=target.error,
+ state=target_meta.state,
+ error=target_meta.error,
succ=succ,
loss=loss,
- # traceroute=target.traceroute,
- # route_loop=target.route_loop,
+ traceroute=target_meta.traceroute,
+ route_loop=target_meta.route_loop,
)
)
From 0445532bdee881433a77601a9ca3ebbee507f80f Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sat, 5 Apr 2025 20:51:51 +0200
Subject: [PATCH 09/19] traceroute background task
---
meshping/meshping/apps.py | 6 +
meshping/meshping/meshping/meshping_config.py | 16 ++-
meshping/meshping/meshping/socklib.py | 109 ++++++++++++++++++
.../meshping/meshping/traceroute_thread.py | 108 +++++++++++++++++
4 files changed, 236 insertions(+), 3 deletions(-)
create mode 100644 meshping/meshping/meshping/socklib.py
create mode 100644 meshping/meshping/meshping/traceroute_thread.py
diff --git a/meshping/meshping/apps.py b/meshping/meshping/apps.py
index e6babc0..2770f6a 100644
--- a/meshping/meshping/apps.py
+++ b/meshping/meshping/apps.py
@@ -8,12 +8,18 @@ class MeshpingConfig(AppConfig):
# TODO decide for long-term layout: standard threads, async, scheduling library
# TODO start background threads for traceroute, peers
+ # TODO with the current thread model, django complains about db access before app
+ # initialization is completed
# TODO create background task for db housekeeping (prune_histograms)
def ready(self):
# delayed import, otherwise we will get AppRegistryNotReady
# pylint: disable=import-outside-toplevel
from .meshping.ping_thread import PingThread
+ from .meshping.traceroute_thread import TracerouteThread
mp_config = MPCOnfig()
ping_thread = PingThread(mp_config=mp_config, daemon=True)
+ traceroute_thread = TracerouteThread(mp_config=mp_config)
+
ping_thread.start()
+ traceroute_thread.start()
diff --git a/meshping/meshping/meshping/meshping_config.py b/meshping/meshping/meshping/meshping_config.py
index 84498d2..9154641 100644
--- a/meshping/meshping/meshping/meshping_config.py
+++ b/meshping/meshping/meshping/meshping_config.py
@@ -1,8 +1,18 @@
# TODO add implementation for values from environment
# TODO decide about option for config file, and automatic combination with docker
# environment variables
+#
+# pylint: disable=too-many-instance-attributes
class MeshpingConfig:
def __init__(self):
- self.ping_timeout = 1
- self.ping_interval = 1
- self.histogram_period = 1
+ self.ping_timeout = 5
+ self.ping_interval = 30
+
+ self.traceroute_interval = 900
+ self.traceroute_timeout = 0.5
+ self.traceroute_packets = 1
+ self.traceroute_ratelimit_interval = 2
+
+ self.whois_cache_validiy_h = 72
+
+ self.histogram_period = 3
diff --git a/meshping/meshping/meshping/socklib.py b/meshping/meshping/meshping/socklib.py
new file mode 100644
index 0000000..e569186
--- /dev/null
+++ b/meshping/meshping/meshping/socklib.py
@@ -0,0 +1,109 @@
+import socket
+
+from icmplib.sockets import ICMPv4Socket, ICMPv6Socket
+from icmplib.exceptions import ICMPSocketError, TimeoutExceeded
+from icmplib.models import ICMPRequest
+from icmplib.utils import unique_identifier
+
+# see /usr/include/linux/in.h
+IP_MTU_DISCOVER = 10
+IP_PMTUDISC_DO = 2
+IP_MTU = 14
+IP_HEADER_LEN = 20
+ICMP_HEADER_LEN = 8
+
+# see /usr/include/linux/in6.h
+IPV6_MTU_DISCOVER = 23
+IPV6_PMTUDISC_DO = 2
+IPV6_MTU = 24
+IPV6_HEADER_LEN = 40
+ICMPV6_HEADER_LEN = 8
+
+
+def reverse_lookup(ip):
+ try:
+ return socket.gethostbyaddr(ip)[0]
+ except socket.herror:
+ return ip
+
+
+class PMTUDv4Socket(ICMPv4Socket):
+ # pylint: disable=redefined-builtin
+ def _create_socket(self, type):
+ sock = super()._create_socket(type)
+ sock.setsockopt(socket.IPPROTO_IP, IP_MTU_DISCOVER, IP_PMTUDISC_DO)
+ return sock
+
+ def get_header_len(self):
+ return IP_HEADER_LEN + ICMP_HEADER_LEN
+
+ def get_mtu(self):
+ return self._sock.getsockopt(socket.IPPROTO_IP, IP_MTU)
+
+ def send(self, request):
+ self._sock.connect((request.destination, 0))
+ return super().send(request)
+
+
+class PMTUDv6Socket(ICMPv6Socket):
+ # pylint: disable=redefined-builtin
+ def _create_socket(self, type):
+ sock = super()._create_socket(type)
+ sock.setsockopt(socket.IPPROTO_IPV6, IPV6_MTU_DISCOVER, IPV6_PMTUDISC_DO)
+ sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_DONTFRAG, 1)
+ return sock
+
+ def get_header_len(self):
+ return IPV6_HEADER_LEN + ICMPV6_HEADER_LEN
+
+ def get_mtu(self):
+ return self._sock.getsockopt(socket.IPPROTO_IPV6, IPV6_MTU)
+
+ def send(self, request):
+ self._sock.connect((request.destination, 0))
+ return super().send(request)
+
+
+def ip_pmtud(ip):
+ mtu = 9999
+
+ try:
+ addrinfo = socket.getaddrinfo(ip, 0, type=socket.SOCK_DGRAM)[0]
+ except socket.gaierror as err:
+ return {"state": "error", "error": str(err), "mtu": mtu}
+
+ if addrinfo[0] == socket.AF_INET6:
+ sock = PMTUDv6Socket(address=None, privileged=True)
+ else:
+ sock = PMTUDv4Socket(address=None, privileged=True)
+
+ with sock:
+ ping_id = unique_identifier()
+ for sequence in range(30):
+ request = ICMPRequest(
+ destination=ip,
+ id=ping_id,
+ sequence=sequence,
+ payload_size=mtu - sock.get_header_len(),
+ )
+ try:
+ # deliberately send a way-too-large packet to provoke an error.
+ # if the ping is successful, we found the MTU.
+ sock.send(request)
+ sock.receive(request, 1)
+ return {"state": "up", "mtu": mtu}
+
+ except TimeoutExceeded:
+ # Target down, but no error -> MTU is probably fine.
+ return {"state": "down", "mtu": mtu}
+
+ except (ICMPSocketError, OSError) as err:
+ if "Errno 90" not in str(err):
+ return {"state": "error", "error": str(err), "mtu": mtu}
+
+ new_mtu = sock.get_mtu()
+ if new_mtu == mtu:
+ break
+ mtu = new_mtu
+
+ return {"state": "ttl_exceeded", "mtu": mtu}
diff --git a/meshping/meshping/meshping/traceroute_thread.py b/meshping/meshping/meshping/traceroute_thread.py
new file mode 100644
index 0000000..23c0b22
--- /dev/null
+++ b/meshping/meshping/meshping/traceroute_thread.py
@@ -0,0 +1,108 @@
+import logging
+import time
+from threading import Thread
+from icmplib import traceroute
+from ipwhois import IPWhois, IPDefinedError
+from netaddr import IPAddress, IPNetwork
+from .socklib import reverse_lookup, ip_pmtud
+from ..models import Target, Meta
+
+
+class TracerouteThread(Thread):
+ def __init__(self, mp_config, *args, **kwargs):
+ self.mp_config = mp_config
+ self.whois_cache = {}
+ super().__init__(*args, **kwargs)
+
+ def whois(self, hop_address):
+ # If we know this address already and it's up-to-date, skip it
+ now = int(time.time())
+ if (
+ hop_address in self.whois_cache
+ and self.whois_cache[hop_address].get("last_check", 0)
+ + self.mp_config.whois_cache_validiy_h * 3600
+ < now
+ ):
+ return self.whois_cache[hop_address]
+
+ # Check if the IP is private or reserved
+ addr = IPAddress(hop_address)
+ # TODO split out into separate function and allow configuration
+ # pylint: disable=too-many-boolean-expressions
+ if (
+ addr.version == 4
+ and (
+ addr in IPNetwork("10.0.0.0/8")
+ or addr in IPNetwork("172.16.0.0/12")
+ or addr in IPNetwork("192.168.0.0/16")
+ or addr in IPNetwork("100.64.0.0/10")
+ )
+ ) or (addr.version == 6 and addr not in IPNetwork("2000::/3")):
+ return {}
+
+ # It's not, look up whois info
+ try:
+ self.whois_cache[hop_address] = dict(
+ IPWhois(hop_address).lookup_rdap(), last_check=now
+ )
+ except IPDefinedError:
+ # RFC1918, RFC6598 or something else
+ return {}
+ # we do not have a global exception handler atm, thus we want to catch all
+ # errors here
+ # pylint: disable=broad-exception-caught
+ except Exception as err:
+ logging.warning("Could not query whois for IP %s: %s", hop_address, err)
+ return self.whois_cache[hop_address]
+
+ def run(self):
+ while True:
+ now = time.time()
+ next_run = now + self.mp_config.traceroute_interval
+ pmtud_cache = {}
+ for target in Target.objects.all():
+ target_meta, _created = Meta.objects.get_or_create(target=target)
+
+ trace = traceroute(
+ target.addr,
+ fast=True,
+ timeout=self.mp_config.traceroute_timeout,
+ count=self.mp_config.traceroute_packets,
+ )
+ hopaddrs = [hop.address for hop in trace]
+ hoaddrs_set = set(hopaddrs)
+ target_meta.route_loop = (
+ len(hopaddrs) != len(hoaddrs_set) and len(hoaddrs_set) > 1
+ )
+
+ trace_hops = []
+ for hop in trace:
+ if hop.address not in pmtud_cache:
+ pmtud_cache[hop.address] = ip_pmtud(hop.address)
+
+ trace_hops.append(
+ {
+ "name": reverse_lookup(hop.address),
+ "distance": hop.distance,
+ "address": hop.address,
+ "max_rtt": hop.max_rtt,
+ "pmtud": pmtud_cache[hop.address],
+ "whois": self.whois(hop.address),
+ "time": now,
+ }
+ )
+
+ target_meta.traceroute = trace_hops
+ if trace_hops and trace_hops[-1]["address"] == target.addr:
+ # Store last known good traceroute
+ target_meta.lkgt = trace_hops
+
+ # Running a bunch'a traceroutes all at once might trigger our default
+ # gw's rate limiting if it receives too many packets with a ttl of 1
+ # too quickly. Let's go a bit slower so that it doesn't stop sending
+ # "ttl exceeded" replies and messing up our results.
+ time.sleep(self.mp_config.traceroute_ratelimit_interval)
+
+ target_meta.save()
+
+ time.sleep(max(0, next_run - time.time()))
From 6c78e0f6fe10bad5cf8055124a9f26e034dade3d Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sat, 5 Apr 2025 21:32:18 +0200
Subject: [PATCH 10/19] peering thread
---
meshping/meshping/apps.py | 10 +-
meshping/meshping/meshping/ifaces.py | 125 ++++++++++++++++++
meshping/meshping/meshping/meshping_config.py | 4 +
meshping/meshping/meshping/peering_thread.py | 70 ++++++++++
.../migrations/0007_meta_is_foreign.py | 18 +++
meshping/meshping/models.py | 1 +
requirements.txt | 3 +-
7 files changed, 227 insertions(+), 4 deletions(-)
create mode 100644 meshping/meshping/meshping/ifaces.py
create mode 100644 meshping/meshping/meshping/peering_thread.py
create mode 100644 meshping/meshping/migrations/0007_meta_is_foreign.py
diff --git a/meshping/meshping/apps.py b/meshping/meshping/apps.py
index 2770f6a..df86e5e 100644
--- a/meshping/meshping/apps.py
+++ b/meshping/meshping/apps.py
@@ -11,15 +11,19 @@ class MeshpingConfig(AppConfig):
# TODO with the current thread model, django complains about db access before app
# initialization is completed
# TODO create background task for db housekeeping (prune_histograms)
+ #
+ # delayed import, otherwise we will get AppRegistryNotReady
+ # pylint: disable=import-outside-toplevel
def ready(self):
- # delayed import, otherwise we will get AppRegistryNotReady
- # pylint: disable=import-outside-toplevel
+ from .meshping.peering_thread import PeeringThread
from .meshping.ping_thread import PingThread
from .meshping.traceroute_thread import TracerouteThread
mp_config = MPCOnfig()
+ peering_thread = PeeringThread(mp_config=mp_config, daemon=True)
ping_thread = PingThread(mp_config=mp_config, daemon=True)
- traceroute_thread = TracerouteThread(mp_config=mp_config)
+ traceroute_thread = TracerouteThread(mp_config=mp_config, daemon=True)
+ peering_thread.start()
ping_thread.start()
traceroute_thread.start()
diff --git a/meshping/meshping/meshping/ifaces.py b/meshping/meshping/meshping/ifaces.py
new file mode 100644
index 0000000..90db9c3
--- /dev/null
+++ b/meshping/meshping/meshping/ifaces.py
@@ -0,0 +1,125 @@
+import socket
+import ipaddress
+import logging
+import netifaces
+
+
+class Ifaces4:
+ def __init__(self):
+ self.addrs = []
+ self.networks = []
+ # Get addresses from our interfaces
+ for iface in netifaces.interfaces():
+ try:
+ ifaddrs = netifaces.ifaddresses(iface)
+ except ValueError:
+ logging.warning(
+ "Could not retrieve addresses of interface %s, ignoring interface",
+ iface,
+ exc_info=True,
+ )
+ continue
+
+ for family, addresses in ifaddrs.items():
+ if family != socket.AF_INET:
+ continue
+
+ for addrinfo in addresses:
+ self.addrs.append(ipaddress.ip_address(addrinfo["addr"]))
+ self.networks.append(
+ ipaddress.IPv4Network(
+ f"{ipaddress.ip_address(addrinfo['addr'])}/"
+ f"{ipaddress.ip_address(addrinfo['netmask'])}",
+ strict=False,
+ )
+ )
+
+ def find_iface_for_network(self, target):
+ target = ipaddress.IPv4Address(target)
+ for addr in self.networks:
+ if target in addr:
+ return addr
+ return None
+
+ def is_local(self, target):
+ return self.find_iface_for_network(target) is not None
+
+ def is_interface(self, target):
+ target = ipaddress.IPv4Address(target)
+ return target in self.addrs
+
+
+class Ifaces6:
+ def __init__(self):
+ self.addrs = []
+ self.networks = []
+ # Get addresses from our interfaces
+ for iface in netifaces.interfaces():
+ try:
+ ifaddrs = netifaces.ifaddresses(iface)
+ except ValueError:
+ logging.warning(
+ "Could not retrieve addresses of interface %s, ignoring interface",
+ iface,
+ exc_info=True,
+ )
+ continue
+
+ for family, addresses in ifaddrs.items():
+ if family != socket.AF_INET6:
+ continue
+
+ for addrinfo in addresses:
+ if "%" in addrinfo["addr"]:
+ part_addr, part_iface = addrinfo["addr"].split("%", 1)
+ assert part_iface == iface
+ addrinfo["addr"] = part_addr
+
+ # netmask is ffff:ffff:ffff:etc:ffff/128 for some reason,
+ # we only need the length
+ addrinfo["netmask"] = int(addrinfo["netmask"].split("/")[1], 10)
+
+ self.addrs.append(ipaddress.ip_address(addrinfo["addr"]))
+ self.networks.append(
+ ipaddress.IPv6Network(
+ f"{ipaddress.ip_address(addrinfo['addr'])}/"
+ f"{addrinfo['netmask']}",
+ strict=False,
+ )
+ )
+
+ def find_iface_for_network(self, target):
+ target = ipaddress.IPv6Address(target)
+ for addr in self.networks:
+ if target in addr:
+ return addr
+ return None
+
+ def is_local(self, target):
+ return self.find_iface_for_network(target) is not None
+
+ def is_interface(self, target):
+ target = ipaddress.IPv4Address(target)
+ return target in self.addrs
+
+
+def test():
+ if4 = Ifaces4()
+ for target in (
+ "192.168.0.1",
+ "10.159.1.1",
+ "10.159.1.2",
+ "10.5.1.2",
+ "10.9.9.9",
+ "8.8.8.8",
+ "192.168.44.150",
+ ):
+ print(f"{target} -> {if4.is_local(target)}")
+
+ if6 = Ifaces6()
+ for target in ("2001:4860:4860::8888", "2001:4860:4860::8844", "::1"):
+ print(f"{target} -> {if6.is_local(target)}")
+
+
+if __name__ == "__main__":
+ test()
diff --git a/meshping/meshping/meshping/meshping_config.py b/meshping/meshping/meshping/meshping_config.py
index 9154641..f7ae31f 100644
--- a/meshping/meshping/meshping/meshping_config.py
+++ b/meshping/meshping/meshping/meshping_config.py
@@ -13,6 +13,10 @@ def __init__(self):
self.traceroute_packets = 1
self.traceroute_ratelimit_interval = 2
+ self.peers = ""
+ self.peering_interval = 30
+ self.peering_timeout = 30
+
self.whois_cache_validiy_h = 72
self.histogram_period = 3
diff --git a/meshping/meshping/meshping/peering_thread.py b/meshping/meshping/meshping/peering_thread.py
new file mode 100644
index 0000000..cf66524
--- /dev/null
+++ b/meshping/meshping/meshping/peering_thread.py
@@ -0,0 +1,70 @@
+import json
+import logging
+import time
+from threading import Thread
+import requests
+from .ifaces import Ifaces4, Ifaces6
+from ..models import Target, Meta
+
+
+# TODO test this code, has not even run once
+
+
+class PeeringThread(Thread):
+ def __init__(self, mp_config, *args, **kwargs):
+ self.mp_config = mp_config
+ super().__init__(*args, **kwargs)
+
+ def run(self):
+ if self.mp_config.peers:
+ peers = self.mp_config.peers.split(",")
+ else:
+ return
+
+ while True:
+ if4 = Ifaces4()
+ if6 = Ifaces6()
+
+ def is_local(addr):
+ try:
+ return if4.is_local(addr)
+ except ValueError:
+ pass
+ try:
+ return if6.is_local(addr)
+ except ValueError:
+ pass
+ return False
+
+ peer_targets = []
+
+ # TODO feels clumsy to get the meta objects one by one, maybe there is a
+ # more elegant way, i.e. something that maps to a join under the hood?
+ for target in Target.objects.all():
+ target_meta, _created = Meta.objects.get_or_create(target=target)
+ if target_meta.is_foreign:
+ continue
+ peer_targets.append(
+ {
+ "name": target.name,
+ "addr": target.addr,
+ "local": is_local(target.addr),
+ }
+ )
+
+ for peer in peers:
+ try:
+ requests.post(
+ f"http://{peer}/peer",
+ headers={
+ "Content-Type": "application/json",
+ },
+ data=json.dumps({"targets": peer_targets}),
+ timeout=self.mp_config.peering_timeout,
+ )
+ # TODO decide if this general exception catch is the correct way
+ # pylint: disable=broad-exception-caught
+ except Exception as err:
+ logging.warning("Could not connect to peer %s: %s", peer, err)
+
+ time.sleep(self.mp_config.peering_interval)
diff --git a/meshping/meshping/migrations/0007_meta_is_foreign.py b/meshping/meshping/migrations/0007_meta_is_foreign.py
new file mode 100644
index 0000000..d0c2b0f
--- /dev/null
+++ b/meshping/meshping/migrations/0007_meta_is_foreign.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.7 on 2025-04-05 19:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("meshping", "0006_alter_meta_lkgt_alter_meta_traceroute"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="meta",
+ name="is_foreign",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py
index de6792b..5d4e18a 100644
--- a/meshping/meshping/models.py
+++ b/meshping/meshping/models.py
@@ -52,3 +52,4 @@ class Meta(models.Model):
# lkgt = last known good traceroute
lkgt = models.JSONField(max_length=2048, default=list)
error = models.CharField(max_length=255, null=True, default=None)
+ is_foreign = models.BooleanField(default=False)
diff --git a/requirements.txt b/requirements.txt
index 10f339d..3982323 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,4 +10,5 @@ packaging
black~=25.1.0
Django~=5.1.7
-Jinja2~=3.1.6
\ No newline at end of file
+Jinja2~=3.1.6
+requests~=2.32.3
\ No newline at end of file
From 25156b53a116a68a2c824e10fc9e3da95aca5bc1 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sat, 5 Apr 2025 21:46:08 +0200
Subject: [PATCH 11/19] deleting targets
---
meshping/meshping/views.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 6df648e..bddd37c 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -82,6 +82,7 @@ def stats(request):
# TODO add traceroute to response for each target
# TODO add route_loop to response for each target
# TODO do not crash when the uniqueness constraint is not met for new targets
+# TODO nasty race condition, retrieving objects can fail when target was just deleted
@require_http_methods(["GET", "POST"])
def targets_endpoint(request):
if request.method == "GET":
@@ -160,5 +161,8 @@ def targets_endpoint(request):
# route /api/targets/
-def edit_target(request, **kwargs):
- return HttpResponseServerError("not implemented")
+@require_http_methods(["DELETE"])
+def edit_target(request, target):
+ if request.method == "DELETE":
+ Target.objects.filter(addr=target).delete()
+ return JsonResponse({"success": True})
From 637ff774204dabcb17bb1dd4caceca9134beac00 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sat, 5 Apr 2025 22:13:06 +0200
Subject: [PATCH 12/19] first part of metrics output
---
meshping/meshping/views.py | 85 +++++++++++++++++++++++++++++++++++++-
1 file changed, 84 insertions(+), 1 deletion(-)
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index bddd37c..1e4b7b8 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -2,6 +2,7 @@
import os
import socket
+from django.forms.models import model_to_dict
from django.http import (
HttpResponse,
HttpResponseBadRequest,
@@ -48,8 +49,90 @@ def histogram(request, **kwargs):
# route /metrics
+#
+# TODO is the metrics output valid prometheus format when no values are present?
def metrics(request):
- return HttpResponseServerError("not implemented")
+ respdata = [
+ "\n".join(
+ [
+ "# HELP meshping_sent Sent pings",
+ "# TYPE meshping_sent counter",
+ "# HELP meshping_recv Received pongs",
+ "# TYPE meshping_recv counter",
+ "# HELP meshping_lost Lost pings (actual counter, not just sent-recv)",
+ "# TYPE meshping_lost counter",
+ "# HELP meshping_max max ping",
+ "# TYPE meshping_max gauge",
+ "# HELP meshping_min min ping",
+ "# TYPE meshping_min gauge",
+ "# HELP meshping_pings Pings bucketed by response time",
+ "# TYPE meshping_pings histogram",
+ ]
+ )
+ ]
+
+ for target in Target.objects.all():
+ target_stats, _created = Statistics.objects.get_or_create(target=target)
+ target_info = dict(
+ model_to_dict(target_stats), addr=target.addr, name=target.name
+ )
+ respdata.append(
+ "\n".join(
+ [
+ 'meshping_sent{name="%(name)s",target="%(addr)s"} %(sent)d',
+ 'meshping_recv{name="%(name)s",target="%(addr)s"} %(recv)d',
+ 'meshping_lost{name="%(name)s",target="%(addr)s"} %(lost)d',
+ ]
+ )
+ % target_info
+ )
+
+ if target_info["recv"]:
+ respdata.append(
+ "\n".join(
+ [
+ 'meshping_max{name="%(name)s",target="%(addr)s"} %(max).2f',
+ 'meshping_min{name="%(name)s",target="%(addr)s"} %(min).2f',
+ ]
+ )
+ % target_info
+ )
+
+ respdata.append(
+ "\n".join(
+ [
+ 'meshping_pings_sum{name="%(name)s",target="%(addr)s"} %(sum)f',
+ 'meshping_pings_count{name="%(name)s",target="%(addr)s"} %(recv)d',
+ ]
+ )
+ % target_info
+ )
+
+ # TODO add the histogram
+ # histogram = target.histogram.tail(1)
+ # count = 0
+ # for bucket in histogram.columns:
+ # if histogram[bucket][0] == 0:
+ # continue
+ # nextping = 2 ** ((bucket + 1) / 10.)
+ # count += histogram[bucket][0]
+ # respdata.append(
+ # 'meshping_pings_bucket{name="%(name)s",target="%(addr)s",le="%(le).2f"} %(count)d' % dict(
+ # addr = target.addr,
+ # count = count,
+ # le = nextping,
+ # name = target.name,
+ # )
+ # )
+ # respdata.append(
+ # 'meshping_pings_bucket{name="%(name)s",target="%(addr)s",le="+Inf"} %(count)d' % dict(
+ # addr = target.addr,
+ # count = count,
+ # name = target.name,
+ # )
+ # )
+
+ return HttpResponse("\n".join(respdata) + "\n", content_type="text/plain")
# route /network.svg
From f7ada64c009c0a1f805e173b083f522fc61d1817 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sun, 6 Apr 2025 11:44:30 +0200
Subject: [PATCH 13/19] histogram data in /metrics endpoint
---
meshping/meshping/meshping/meshping_config.py | 2 +
meshping/meshping/meshping/ping_thread.py | 2 +-
meshping/meshping/views.py | 64 +++++++++++--------
3 files changed, 42 insertions(+), 26 deletions(-)
diff --git a/meshping/meshping/meshping/meshping_config.py b/meshping/meshping/meshping/meshping_config.py
index f7ae31f..df3200f 100644
--- a/meshping/meshping/meshping/meshping_config.py
+++ b/meshping/meshping/meshping/meshping_config.py
@@ -17,6 +17,8 @@ def __init__(self):
self.peering_interval = 30
self.peering_timeout = 30
+ # TODO make config options in this object consistent, use seconds
self.whois_cache_validiy_h = 72
+ # TODO make config options in this object consistent, use seconds
self.histogram_period = 3
diff --git a/meshping/meshping/meshping/ping_thread.py b/meshping/meshping/meshping/ping_thread.py
index 22301e9..54335d0 100644
--- a/meshping/meshping/meshping/ping_thread.py
+++ b/meshping/meshping/meshping/ping_thread.py
@@ -82,7 +82,7 @@ def run(self):
# TODO has nothing to do with pings, find a better place (probably new
# housekeeping thread)
Histogram.objects.filter(
- timestamp__lt=(now - self.mp_config.histogram_period)
+ timestamp__lt=(now - self.mp_config.histogram_period * 24 * 3600)
).delete()
unseen_targets = current_targets.copy()
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 1e4b7b8..2ba81cf 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -9,11 +9,12 @@
HttpResponseServerError,
JsonResponse,
)
-from django.views.decorators.http import require_http_methods
from django.template import loader
+from django.views.decorators.http import require_http_methods
+from django_pivot.pivot import pivot
from markupsafe import Markup
-from .models import Statistics, Target, Meta
+from .models import Statistics, Target, Meta, Histogram
# TODO remove business logic in this file
@@ -108,29 +109,42 @@ def metrics(request):
% target_info
)
- # TODO add the histogram
- # histogram = target.histogram.tail(1)
- # count = 0
- # for bucket in histogram.columns:
- # if histogram[bucket][0] == 0:
- # continue
- # nextping = 2 ** ((bucket + 1) / 10.)
- # count += histogram[bucket][0]
- # respdata.append(
- # 'meshping_pings_bucket{name="%(name)s",target="%(addr)s",le="%(le).2f"} %(count)d' % dict(
- # addr = target.addr,
- # count = count,
- # le = nextping,
- # name = target.name,
- # )
- # )
- # respdata.append(
- # 'meshping_pings_bucket{name="%(name)s",target="%(addr)s",le="+Inf"} %(count)d' % dict(
- # addr = target.addr,
- # count = count,
- # name = target.name,
- # )
- # )
+ # TODO add proper explanation
+ # [
+ # {
+ # "timestamp": 1743883200,
+ # "47": 2,
+ # "50": null,
+ # ...
+ # },
+ # ...
+ # ]
+ #
+ # TODO make sure that result is always ordered by timestamp
+ # TODO error handling if there is no line in the result
+ hist = pivot(
+ Histogram.objects.filter(target=target), "timestamp", "bucket", "count"
+ )[-1]
+ count = 0
+ for bucket in hist.keys():
+ if bucket == "timestamp":
+ continue
+ if not hist[bucket]:
+ continue
+ nextping = 2 ** ((int(bucket) + 1) / 10.0)
+ count += hist[bucket]
+ respdata.append(
+ (
+ f'meshping_pings_bucket{{name="{target.name}",'
+ f'target="{target.addr}",le="{nextping:.2f}"}} {count}'
+ )
+ )
+ respdata.append(
+ (
+ f'meshping_pings_bucket{{name="{target.name}",'
+ f'target="{target.addr}",le="+Inf"}} {count}'
+ )
+ )
return HttpResponse("\n".join(respdata) + "\n", content_type="text/plain")
From 1b15ddef9d004682305b6a5f65a3c817c102733b Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sun, 6 Apr 2025 19:37:02 +0200
Subject: [PATCH 14/19] drawing histograms, including comparisons
---
meshping/meshping/meshping/histodraw.py | 246 ++++++++++++++++++++++++
meshping/meshping/models.py | 25 +++
meshping/meshping/views.py | 54 ++++--
3 files changed, 306 insertions(+), 19 deletions(-)
create mode 100644 meshping/meshping/meshping/histodraw.py
diff --git a/meshping/meshping/meshping/histodraw.py b/meshping/meshping/meshping/histodraw.py
new file mode 100644
index 0000000..902407d
--- /dev/null
+++ b/meshping/meshping/meshping/histodraw.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# kate: space-indent on; indent-width 4; replace-tabs on;
+
+import socket
+import os
+from datetime import timedelta
+import numpy as np
+import pandas
+
+from PIL import Image, ImageDraw, ImageFont, ImageOps
+from ..models import target_histograms
+
+
+# How big do you want the squares to be?
+sqsz = 8
+
+
+# [
+# {
+# "timestamp": 1743883200,
+# "47": 2,
+# "50": null,
+# ...
+# },
+# ...
+# ]
+def render_target(target):
+ histograms_df = target_histograms(target)
+ if histograms_df.empty:
+ return None
+
+ # Normalize Buckets by transforming the number of actual pings sent
+ # into a float [0..1] indicating the grayness of that bucket.
+ biggestbkt = histograms_df.max().max()
+ histograms_df = histograms_df.div(biggestbkt, axis="index")
+ # prune outliers -> keep only values > 5%
+ histograms_df = histograms_df[histograms_df > 0.05]
+ # drop columns that contain only NaNs now
+ histograms_df = histograms_df.dropna(axis="columns", how="all")
+ # fill missing _rows_ (aka, hours) with rows of just NaN
+ histograms_df = histograms_df.asfreq("1h")
+ # replace all the NaNs with 0
+ histograms_df = histograms_df.fillna(0)
+
+ # detect dynamic range, and round to the nearest multiple of 10.
+ # this ensures that the ticks are drawn at powers of 2, which makes
+ # the graph more easily understandable. (I hope.)
+ # Btw: 27 // 10 * 10 # = 20
+ # -27 // 10 * 10 # = -30
+ # hmax needs to be nearest power of 10 + 1 for the top tick to be drawn.
+ hmin = histograms_df.columns.min() // 10 * 10
+ hmax = histograms_df.columns.max() // 10 * 10 + 11
+
+ # Draw the graph in a pixels array which we then copy to an image
+ height = hmax - hmin + 1
+ width = len(histograms_df)
+ pixels = np.zeros(width * height)
+
+ for col, (_tstamp, histogram) in enumerate(histograms_df.iterrows()):
+ for bktval, bktgrayness in histogram.items():
+ # ( y ) (x)
+ pixels[((hmax - bktval) * width) + col] = bktgrayness
+
+ # copy pixels to an Image and paste that into the output image
+ graph = Image.new("L", (width, height))
+ graph.putdata(pixels * 0xFF)
+
+ # Scale graph so each Pixel becomes a square
+ width *= sqsz
+ height *= sqsz
+
+ graph = graph.resize((width, height), Image.NEAREST)
+ graph.hmin = hmin
+ graph.hmax = hmax
+ graph.tmin = histograms_df.index.min()
+ graph.tmax = histograms_df.index.max()
+ return graph
+
+
+# TODO maybe this method is a bit long? on the other hand, manual rendering code always
+# tends to be tedious
+# pylint: disable=too-many-locals,too-many-branches,too-many-statements
+def render(targets, histogram_period):
+ rendered_graphs = []
+
+ for target in targets:
+ target_graph = render_target(target)
+ if target_graph is None:
+ raise ValueError(f"No data available for target {target}")
+ rendered_graphs.append(target_graph)
+
+ width = histogram_period // 3600 * sqsz
+ hmin = min(graph.hmin for graph in rendered_graphs)
+ hmax = max(graph.hmax for graph in rendered_graphs)
+ tmax = max(graph.tmax for graph in rendered_graphs)
+ height = (hmax - hmin) * sqsz
+
+ if len(rendered_graphs) == 1:
+ # Single graph -> use it as-is
+ graph = Image.new("L", (width, height), "white")
+ graph.paste(
+ ImageOps.invert(rendered_graphs[0]), (width - rendered_graphs[0].width, 0)
+ )
+ else:
+ # Multiple graphs -> merge.
+ # This width/height may not match what we need for the output.
+ # Check for which graphs that is the case, and for these,
+ # create a new image that has the correct size and paste
+ # the graph into it.
+ resized_graphs = []
+ for graph in rendered_graphs:
+ dtmax = (tmax - graph.tmax) // pandas.Timedelta(hours=1)
+ if graph.width != width or graph.height != height:
+ new_graph = Image.new("L", (width, height), "black")
+ new_graph.paste(
+ graph,
+ (width - graph.width - sqsz * dtmax, (hmax - graph.hmax) * sqsz),
+ )
+ else:
+ new_graph = graph
+
+ resized_graphs.append(new_graph)
+
+ while len(resized_graphs) != 3:
+ resized_graphs.append(Image.new("L", (width, height), "black"))
+
+ # Print the graph, on black background still.
+ graph = Image.merge("RGB", resized_graphs)
+
+ # To get a white background, convert to HSV and set V=1.
+ # V currently contains the interesting information though,
+ # so move that to S first.
+ hsv = np.array(graph.convert("HSV"))
+ # Add V to S (not sure why adding works better than replacing, but it does)
+ hsv[:, :, 1] = hsv[:, :, 1] + hsv[:, :, 2]
+ # Set V to 1
+ hsv[:, :, 2] = np.ones((height, width)) * 0xFF
+ graph = Image.fromarray(hsv, "HSV").convert("RGB")
+
+ # position of the graph
+ graph_x = 70
+ graph_y = 30 * len(targets) + 10
+
+ # im will hold the output image
+ im = Image.new("RGB", (graph_x + width + 20, graph_y + height + 100), "white")
+ im.paste(graph, (graph_x, graph_y))
+
+ # draw a rect around the graph
+ draw = ImageDraw.Draw(im)
+ draw.rectangle(
+ (graph_x, graph_y, graph_x + width - 1, graph_y + height - 1), outline=0x333333
+ )
+
+ try:
+ font = ImageFont.truetype("DejaVuSansMono.ttf", 10)
+ lgfont = ImageFont.truetype("DejaVuSansMono.ttf", 16)
+ except IOError:
+ font = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 10
+ )
+ lgfont = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 16
+ )
+
+ # Headline
+ if len(targets) == 1: # just black for a single graph
+ targets_with_colors = zip(targets, (0x000000,))
+ else: # red, green, blue for multiple graphs
+ targets_with_colors = zip(targets, (0x0000FF, 0x00FF00, 0xFF0000))
+
+ for idx, (target, color) in enumerate(targets_with_colors):
+ headline_text = f"{socket.gethostname()} → {target.label}"
+ headline_width = lgfont.getlength(headline_text)
+ draw.text(
+ ((graph_x + width + 20 - headline_width) // 2, 30 * idx + 11),
+ headline_text,
+ color,
+ font=lgfont,
+ )
+
+ # Y axis ticks and annotations
+ for hidx in range(hmin, hmax, 5):
+ bottomrow = hidx - hmin
+ offset_y = height + graph_y - bottomrow * sqsz - 1
+ draw.line((graph_x - 2, offset_y, graph_x + 2, offset_y), fill=0xAAAAAA)
+
+ ping = 2 ** (hidx / 10.0)
+ label = f"{ping:.2f}"
+ draw.text(
+ (graph_x - len(label) * 6 - 10, offset_y - 5), label, 0x333333, font=font
+ )
+
+ # Calculate the times at which the histogram begins and ends.
+ t_hist_end = (
+ # Latest hour for which we have data...
+ tmax.tz_localize("Etc/UTC")
+ .tz_convert(os.environ.get("TZ", "Etc/UTC"))
+ .to_pydatetime()
+ # Plus the current hour which we're also drawing on screen
+ + timedelta(hours=1)
+ )
+
+ td_hours = histogram_period // 3600
+ t_hist_begin = t_hist_end - timedelta(hours=td_hours)
+
+ # X axis ticks - one every two hours
+ for col in range(1, width // sqsz):
+ # We're now at hour indicated by col
+ if (t_hist_begin + timedelta(hours=col)).hour % 2 != 0:
+ continue
+ offset_x = graph_x + col * sqsz
+ draw.line(
+ (offset_x, height + graph_y - 2, offset_x, height + graph_y + 2),
+ fill=0xAAAAAA,
+ )
+
+ # X axis annotations
+ # Create a temp image for the bottom label that we then rotate by 90° and attach to
+ # the other one since this stuff is rotated by 90° while we create it, all the
+ # coordinates are inversed...
+ tmpim = Image.new("RGB", (80, width + 20), "white")
+ tmpdraw = ImageDraw.Draw(tmpim)
+
+ # Draw one annotation every four hours
+ for col in range(0, width // sqsz + 1):
+ # We're now at hour indicated by col
+ tstamp = t_hist_begin + timedelta(hours=col)
+ if tstamp.hour % 4 != 0:
+ continue
+ offset_x = col * sqsz
+ if tstamp.hour == 0:
+ tmpdraw.text(
+ (0, offset_x + 4), tstamp.strftime("%m-%d"), 0x333333, font=font
+ )
+ tmpdraw.text((36, offset_x + 4), tstamp.strftime("%H:%M"), 0x333333, font=font)
+
+ im.paste(tmpim.rotate(90, expand=1), (graph_x - 10, height + graph_y + 1))
+
+ # This worked pretty well for Tobi Oetiker...
+ tmpim = Image.new("RGB", (170, 13), "white")
+ tmpdraw = ImageDraw.Draw(tmpim)
+ tmpdraw.text((0, 0), "Meshping by Michael Ziegler", 0x999999, font=font)
+ im.paste(tmpim.rotate(270, expand=1), (width + graph_x + 7, graph_y))
+
+ return im
diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py
index 5d4e18a..7c546e2 100644
--- a/meshping/meshping/models.py
+++ b/meshping/meshping/models.py
@@ -1,4 +1,5 @@
from django.db import models
+import pandas
# TODO decide on max_length, even though ignored by sqlite
@@ -9,6 +10,12 @@ class Target(models.Model):
class Meta:
unique_together = ("addr", "name")
+ @property
+ def label(self):
+ if self.name == self.addr:
+ return self.name
+ return f"{self.name} ({self.addr})"
+
# TODO uniqueness constraint `UNIQUE (target_id, timestamp, bucket)`
class Histogram(models.Model):
@@ -53,3 +60,21 @@ class Meta(models.Model):
lkgt = models.JSONField(max_length=2048, default=list)
error = models.CharField(max_length=255, null=True, default=None)
is_foreign = models.BooleanField(default=False)
+
+
+# TODO consider making this a Target property, but take care that Histogram is defined
+# before Target
+#
+# pivot method: flip the dataframe: turn each value of the "bucket" DB column into a
+# separate column in the DF, using the timestamp as the index and the count for the
+# values. None-existing positions in the DF are filled with zero.
+def target_histograms(target):
+ df = pandas.DataFrame.from_dict(
+ Histogram.objects.filter(target=target).order_by("timestamp", "bucket").values()
+ )
+ df["timestamp"] = pandas.to_datetime(df["timestamp"], unit="s")
+ return df.pivot(
+ index="timestamp",
+ columns="bucket",
+ values="count",
+ ).fillna(0)
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 2ba81cf..a8ca1e9 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -1,20 +1,22 @@
import json
import os
import socket
+from datetime import datetime
from django.forms.models import model_to_dict
from django.http import (
HttpResponse,
HttpResponseBadRequest,
+ HttpResponseNotFound,
HttpResponseServerError,
JsonResponse,
)
from django.template import loader
from django.views.decorators.http import require_http_methods
-from django_pivot.pivot import pivot
from markupsafe import Markup
-from .models import Statistics, Target, Meta, Histogram
+from .meshping import histodraw
+from .models import Statistics, Target, Meta, target_histograms
# TODO remove business logic in this file
@@ -24,6 +26,7 @@
# TODO do not load icons from disk for every request
# TODO find a better method for finding the icons to not have an absolute path here
# TODO make local development possible when node modules are on disk (relative path)
+@require_http_methods(["GET"])
def index(request):
def read_svg_file(icons_dir, filename):
with open(os.path.join(icons_dir, filename), "r", encoding="utf-8") as f:
@@ -45,13 +48,40 @@ def read_svg_file(icons_dir, filename):
# route /histogram//.png
-def histogram(request, **kwargs):
- return HttpResponseServerError("not implemented")
+@require_http_methods(["GET"])
+def histogram(request, node, target):
+ targets = []
+ for arg_target in [target] + request.GET.getlist("compare", default=None):
+ try:
+ targets.append(Target.objects.get(addr=arg_target))
+ except Target.DoesNotExist:
+ return HttpResponseNotFound(f"Target {arg_target} not found")
+
+ if len(targets) > 3:
+ # an RGB image only has three channels
+ return HttpResponseBadRequest("Can only compare up to three targets")
+
+ try:
+ # TODO reference the configuration, with the current setup there is no
+ # obvious clean way (at least to me)
+ img = histodraw.render(targets, 3 * 24 * 3600)
+ except ValueError as err:
+ return HttpResponseNotFound(str(err))
+
+ response = HttpResponse(content_type="image/png")
+ img.save(response, "png")
+ response["refresh"] = "300"
+ response["content-disposition"] = (
+ 'inline; filename="meshping_'
+ f'{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}_{target}.png"'
+ )
+ return response
# route /metrics
#
# TODO is the metrics output valid prometheus format when no values are present?
+@require_http_methods(["GET"])
def metrics(request):
respdata = [
"\n".join(
@@ -109,22 +139,8 @@ def metrics(request):
% target_info
)
- # TODO add proper explanation
- # [
- # {
- # "timestamp": 1743883200,
- # "47": 2,
- # "50": null,
- # ...
- # },
- # ...
- # ]
- #
- # TODO make sure that result is always ordered by timestamp
# TODO error handling if there is no line in the result
- hist = pivot(
- Histogram.objects.filter(target=target), "timestamp", "bucket", "count"
- )[-1]
+ hist = target_histograms(target)[-1]
count = 0
for bucket in hist.keys():
if bucket == "timestamp":
From ecdf2340d7d7786567c3d3ef7526813296f024b0 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sun, 6 Apr 2025 21:06:07 +0200
Subject: [PATCH 15/19] network diagram endpoint
---
meshping/meshping/models.py | 32 +++++++
meshping/meshping/templates/network.puml.j2 | 43 +++++++++
meshping/meshping/views.py | 98 +++++++++++++++++++--
3 files changed, 167 insertions(+), 6 deletions(-)
create mode 100644 meshping/meshping/templates/network.puml.j2
diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py
index 7c546e2..397e382 100644
--- a/meshping/meshping/models.py
+++ b/meshping/meshping/models.py
@@ -1,3 +1,4 @@
+from itertools import zip_longest
from django.db import models
import pandas
@@ -78,3 +79,34 @@ def target_histograms(target):
columns="bucket",
values="count",
).fillna(0)
+
+
+# TODO consider making this a Target property, but take care that Meta is defined
+# before Target
+def target_traceroute(target):
+ target_meta, _created = Meta.objects.get_or_create(target=target)
+
+ curr = target_meta.traceroute
+ lkgt = target_meta.lkgt # last known good traceroute
+ if not curr or not lkgt or len(lkgt) < len(curr):
+ # we probably don't know all the nodes, but the ones we do know are up
+ return [dict(hop, state="up") for hop in curr]
+ if curr[-1]["address"] == target.addr:
+ # Trace has reached the target itself, thus all hops are up
+ return [dict(hop, state="up") for hop in curr]
+
+ # Check with lkgt to see which hops are still there
+ result = []
+ for lkgt_hop, curr_hop in zip_longest(lkgt, curr):
+ if lkgt_hop is None:
+ # This should not be able to happen, because we checked
+ # len(lkgt) < len(curr) above.
+ raise ValueError("last known good traceroute: hop is None")
+ if curr_hop is None:
+ # hops missing from current traceroute are down
+ result.append(dict(lkgt_hop, state="down"))
+ elif curr_hop.get("address") != lkgt_hop.get("address"):
+ result.append(dict(curr_hop, state="different"))
+ else:
+ result.append(dict(curr_hop, state="up"))
+ return result
diff --git a/meshping/meshping/templates/network.puml.j2 b/meshping/meshping/templates/network.puml.j2
new file mode 100644
index 0000000..dfd3bac
--- /dev/null
+++ b/meshping/meshping/templates/network.puml.j2
@@ -0,0 +1,43 @@
+@startuml
+
+
+
+hide <> stereotype
+hide <> stereotype
+hide <> stereotype
+
+
+title Network Map
+
+node "{[ hostname ]}" <> as SELF
+
+{% for hop in uniq_hops_sorted -%}
+{% if hop.address -%}
+node "{% if hop.target %}{[ hop.target.name ]}{% else %}{[ hop.name ]}{% endif %}\n{[ hop.address ]}{% if hop.whois %}\n{[ hop.whois.network.name ]}{% endif %}{% if hop.target %}\n[[{[ url('histogram', kwargs={'node': hostname, 'target': hop.target.addr})]} Histogram]]{% endif %}" <> as {[ hop.id ]}
+{% else -%}
+rectangle "?" as {[ hop.id ]}
+{% endif -%}
+{% endfor -%}
+
+{% for (lft, rgt) in uniq_links -%}
+"{[ lft ]}" -- "{[ rgt ]}"
+{% endfor -%}
+
+footer rendered on {[ now ]} by [[http://github.com/Svedrin/meshping Meshping]] using PlantUML
+@enduml
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index a8ca1e9..ad19e93 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -2,6 +2,8 @@
import os
import socket
from datetime import datetime
+from random import randint
+from subprocess import run as run_command
from django.forms.models import model_to_dict
from django.http import (
@@ -16,7 +18,7 @@
from markupsafe import Markup
from .meshping import histodraw
-from .models import Statistics, Target, Meta, target_histograms
+from .models import Statistics, Target, Meta, target_histograms, target_traceroute
# TODO remove business logic in this file
@@ -166,8 +168,96 @@ def metrics(request):
# route /network.svg
+#
+# note: plantuml in the ubuntu 24.04 apt repo is version 1.2020.02, which gives a
+# broken output. newer version required, known to work with 1.2024.4
+#
+# TODO split out some logic here, this probably reduces the amount of local vars
+# pylint: disable=too-many-locals
+@require_http_methods(["GET"])
def network(request):
- return HttpResponseServerError("not implemented")
+ targets = Target.objects.all()
+ uniq_hops = {}
+ uniq_links = set()
+
+ for target in targets:
+ prev_hop = "SELF"
+ prev_dist = 0
+ for hop in target_traceroute(target):
+ hop_id = hop["address"].replace(":", "_").replace(".", "_")
+
+ # Check if we know this hop already. If we do, just skip ahead.
+ if hop_id not in uniq_hops:
+ # Fill in the blanks for missing hops, if any
+ while hop["distance"] > prev_dist + 1:
+ dummy_id = str(randint(10000000, 99999999))
+ dummy = {
+ "id": dummy_id,
+ "distance": (prev_dist + 1),
+ "address": None,
+ "name": None,
+ "target": None,
+ "whois": None,
+ }
+ uniq_hops[dummy_id] = dummy
+ uniq_links.add((prev_hop, dummy_id))
+ prev_hop = dummy_id
+ prev_dist += 1
+
+ # Now render the hop itself
+ hop_id = hop["address"].replace(":", "_").replace(".", "_")
+ uniq_hops.setdefault(hop_id, dict(hop, id=hop_id, target=None))
+ uniq_links.add((prev_hop, hop_id))
+
+ # make sure we show the most recent state info
+ if (
+ uniq_hops[hop_id]["state"] != hop["state"]
+ and uniq_hops[hop_id]["time"] < hop["time"]
+ ):
+ uniq_hops[hop_id].update(state=hop["state"], time=hop["time"])
+
+ if hop["address"] == target.addr:
+ uniq_hops[hop_id]["target"] = target
+
+ prev_hop = hop_id
+ prev_dist = hop["distance"]
+
+ now = datetime.now()
+
+ context = {
+ "hostname": socket.gethostname(),
+ "now": now.strftime("%Y-%m-%d %H:%M:%S"),
+ "targets": targets,
+ "uniq_hops": uniq_hops,
+ "uniq_links": sorted(uniq_links),
+ "uniq_hops_sorted": [uniq_hops[hop] for hop in sorted(uniq_hops.keys())],
+ }
+ tpl = loader.get_template("network.puml.j2").render(context)
+
+ plantuml = run_command(
+ ["plantuml", "-tsvg", "-p"],
+ input=tpl.encode("utf-8"),
+ capture_output=True,
+ check=False,
+ )
+
+ if plantuml.stderr:
+ return HttpResponseServerError(
+ plantuml.stderr.decode("utf-8") + "\n\n===\n\n" + tpl,
+ content_type="text/plain",
+ )
+
+ resp = HttpResponse(plantuml.stdout, content_type="image/svg+xml")
+
+ resp["refresh"] = "43200" # 12h
+ resp["Cache-Control"] = "max-age=36000, public" # 10h
+
+ resp["content-disposition"] = (
+ 'inline; filename="meshping_'
+ f'{now.strftime("%Y-%m-%d_%H-%M-%S")}_network.svg"'
+ )
+
+ return resp
# route /peer
@@ -190,10 +280,6 @@ def stats(request):
# django ensures a valid http request method, so we do not need a return value
# pylint: disable=inconsistent-return-statements
#
-# TODO add state to response for each target
-# TODO add error to response for each target
-# TODO add traceroute to response for each target
-# TODO add route_loop to response for each target
# TODO do not crash when the uniqueness constraint is not met for new targets
# TODO nasty race condition, retrieving objects can fail when target was just deleted
@require_http_methods(["GET", "POST"])
From f4fbf3ecd493184b3481b9b7e6fd638687ef5a6b Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sun, 6 Apr 2025 21:11:32 +0200
Subject: [PATCH 16/19] api/resolve endpoint
---
meshping/meshping/views.py | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index ad19e93..2d23a46 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -266,8 +266,20 @@ def peer(request):
# route /api/resolve/
-def resolve(request, **kwargs):
- return HttpResponseServerError("not implemented")
+@require_http_methods(["GET"])
+def resolve(request, name):
+ try:
+ return JsonResponse(
+ {
+ "success": True,
+ "addrs": [
+ info[4][0]
+ for info in socket.getaddrinfo(name, 0, 0, socket.SOCK_STREAM)
+ ],
+ }
+ )
+ except socket.gaierror as err:
+ return JsonResponse({"success": False, "error": str(err)})
# route /api/stats
From 6a992192076c82d25ea80ac095946e99f932a46f Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sun, 6 Apr 2025 21:21:18 +0200
Subject: [PATCH 17/19] delete stats endpoint
---
.../migrations/0008_alter_meta_state.py | 27 +++++++++++++++++++
meshping/meshping/models.py | 2 +-
meshping/meshping/views.py | 17 ++++++++++--
3 files changed, 43 insertions(+), 3 deletions(-)
create mode 100644 meshping/meshping/migrations/0008_alter_meta_state.py
diff --git a/meshping/meshping/migrations/0008_alter_meta_state.py b/meshping/meshping/migrations/0008_alter_meta_state.py
new file mode 100644
index 0000000..3081c20
--- /dev/null
+++ b/meshping/meshping/migrations/0008_alter_meta_state.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.1.7 on 2025-04-06 19:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("meshping", "0007_meta_is_foreign"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="meta",
+ name="state",
+ field=models.CharField(
+ choices=[
+ ("up", "Up"),
+ ("down", "Down"),
+ ("unknown", "Unknown"),
+ ("error", "Error"),
+ ],
+ default="unknown",
+ max_length=10,
+ ),
+ ),
+ ]
diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py
index 397e382..254585a 100644
--- a/meshping/meshping/models.py
+++ b/meshping/meshping/models.py
@@ -53,7 +53,7 @@ class TargetState(models.TextChoices):
class Meta(models.Model):
target = models.OneToOneField(Target, on_delete=models.CASCADE, primary_key=True)
state = models.CharField(
- max_length=10, choices=TargetState.choices, default=TargetState.DOWN
+ max_length=10, choices=TargetState.choices, default=TargetState.UNKNOWN
)
route_loop = models.BooleanField(default=False)
traceroute = models.JSONField(max_length=2048, default=list)
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 2d23a46..94cb3ef 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -18,7 +18,14 @@
from markupsafe import Markup
from .meshping import histodraw
-from .models import Statistics, Target, Meta, target_histograms, target_traceroute
+from .models import (
+ Statistics,
+ Target,
+ TargetState,
+ Meta,
+ target_histograms,
+ target_traceroute,
+)
# TODO remove business logic in this file
@@ -283,8 +290,14 @@ def resolve(request, name):
# route /api/stats
+#
+# TODO possible race condition: upon deletion, the /api/targets endpoint might have
+# incorrect assumptions due to the statistics objects disappearing
+@require_http_methods(["DELETE"])
def stats(request):
- return HttpResponseServerError("not implemented")
+ Statistics.objects.all().delete()
+ Meta.objects.all().update(state=TargetState.UNKNOWN)
+ return JsonResponse({"success": True})
# route /api/targets
From 6f34e3fbc4e70b1d0747cbb3ec480aa33da2cd2e Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sun, 6 Apr 2025 21:43:24 +0200
Subject: [PATCH 18/19] peer api endpoint
---
meshping/meshping/views.py | 62 +++++++++++++++++++++++++++++++++++++-
1 file changed, 61 insertions(+), 1 deletion(-)
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 94cb3ef..5b17ef0 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -17,6 +17,7 @@
from django.views.decorators.http import require_http_methods
from markupsafe import Markup
+from .meshping.ifaces import Ifaces4
from .meshping import histodraw
from .models import (
Statistics,
@@ -268,8 +269,67 @@ def network(request):
# route /peer
+#
+# Allows peers to POST a json structure such as this:
+# {
+# "targets": [
+# { "name": "raspi", "addr": "192.168.0.123", "local": true },
+# { "name": "google", "addr": "8.8.8.8", "local": false }
+# ]
+# }
+# The non-local targets will then be added to our target list
+# and stats will be returned for these targets (if known).
+# Local targets will only be added if they are also local to us.
+@require_http_methods(["POST"])
def peer(request):
- return HttpResponseServerError("not implemented")
+ request_json = json.loads(request.body)
+
+ if request_json is None:
+ return HttpResponseBadRequest("Please send content-type:application/json")
+
+ if not isinstance(request_json.get("targets"), list):
+ return HttpResponseBadRequest("need targets as a list")
+
+ ret_stats = []
+ if4 = Ifaces4()
+
+ for target in request_json["targets"]:
+ if not isinstance(target, dict):
+ return HttpResponseBadRequest("targets must be dicts")
+ if (
+ not target.get("name", "").strip()
+ or not target.get("addr", "").strip()
+ or not isinstance(target.get("local"), bool)
+ ):
+ return HttpResponseBadRequest("required field missing in target")
+
+ target["name"] = target["name"].strip()
+ target["addr"] = target["addr"].strip()
+
+ if if4.is_interface(target["addr"]):
+ # no need to ping my own interfaces, ignore
+ continue
+
+ if target["local"] and not if4.is_local(target["addr"]):
+ continue
+
+ # See if we know this target already, otherwise create it.
+ tgt, created = Target.objects.get_or_create(
+ addr=target["addr"], name=target["name"]
+ )
+ if created:
+ tgt_meta, _created = Meta.objects.get_or_create(target=tgt)
+ tgt_meta.is_foreign = True
+ tgt_meta.save()
+ target_stats, _created = Statistics.objects.get_or_create(target=tgt)
+ ret_stats.append(model_to_dict(target_stats))
+
+ return JsonResponse(
+ {
+ "success": True,
+ "targets": ret_stats,
+ }
+ )
# route /api/resolve/
From b41495b8d7e411f01102da8f720b6d644b45c713 Mon Sep 17 00:00:00 2001
From: liquid-metal <56035107+liquid-metal@users.noreply.github.com>
Date: Sun, 6 Apr 2025 22:07:39 +0200
Subject: [PATCH 19/19] make pylint even happier
---
.pylintrc | 2 +-
meshping/meshping/meshping/histodraw.py | 22 +++++++++----------
meshping/meshping/meshping/meshping_config.py | 2 +-
meshping/meshping/models.py | 1 +
meshping/meshping/views.py | 8 +++++--
5 files changed, 20 insertions(+), 15 deletions(-)
diff --git a/.pylintrc b/.pylintrc
index e0de6a4..e4f3034 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -6,7 +6,7 @@ ignore-paths = meshping/.[^/]+/migrations
max-line-length=88
[MESSAGES CONTROL]
-disable=E1101,E1102,E1103,C0111,C0103,W0613,W0108,W0212,R0903,W0511
+disable=missing-function-docstring,missing-class-docstring,missing-module-docstring,no-member,fixme
[DESIGN]
max-public-methods=100
diff --git a/meshping/meshping/meshping/histodraw.py b/meshping/meshping/meshping/histodraw.py
index 902407d..60d5107 100644
--- a/meshping/meshping/meshping/histodraw.py
+++ b/meshping/meshping/meshping/histodraw.py
@@ -13,7 +13,7 @@
# How big do you want the squares to be?
-sqsz = 8
+SQSZ = 8
# [
@@ -67,8 +67,8 @@ def render_target(target):
graph.putdata(pixels * 0xFF)
# Scale graph so each Pixel becomes a square
- width *= sqsz
- height *= sqsz
+ width *= SQSZ
+ height *= SQSZ
graph = graph.resize((width, height), Image.NEAREST)
graph.hmin = hmin
@@ -90,11 +90,11 @@ def render(targets, histogram_period):
raise ValueError(f"No data available for target {target}")
rendered_graphs.append(target_graph)
- width = histogram_period // 3600 * sqsz
+ width = histogram_period // 3600 * SQSZ
hmin = min(graph.hmin for graph in rendered_graphs)
hmax = max(graph.hmax for graph in rendered_graphs)
tmax = max(graph.tmax for graph in rendered_graphs)
- height = (hmax - hmin) * sqsz
+ height = (hmax - hmin) * SQSZ
if len(rendered_graphs) == 1:
# Single graph -> use it as-is
@@ -115,7 +115,7 @@ def render(targets, histogram_period):
new_graph = Image.new("L", (width, height), "black")
new_graph.paste(
graph,
- (width - graph.width - sqsz * dtmax, (hmax - graph.hmax) * sqsz),
+ (width - graph.width - SQSZ * dtmax, (hmax - graph.hmax) * SQSZ),
)
else:
new_graph = graph
@@ -182,7 +182,7 @@ def render(targets, histogram_period):
# Y axis ticks and annotations
for hidx in range(hmin, hmax, 5):
bottomrow = hidx - hmin
- offset_y = height + graph_y - bottomrow * sqsz - 1
+ offset_y = height + graph_y - bottomrow * SQSZ - 1
draw.line((graph_x - 2, offset_y, graph_x + 2, offset_y), fill=0xAAAAAA)
ping = 2 ** (hidx / 10.0)
@@ -205,11 +205,11 @@ def render(targets, histogram_period):
t_hist_begin = t_hist_end - timedelta(hours=td_hours)
# X axis ticks - one every two hours
- for col in range(1, width // sqsz):
+ for col in range(1, width // SQSZ):
# We're now at hour indicated by col
if (t_hist_begin + timedelta(hours=col)).hour % 2 != 0:
continue
- offset_x = graph_x + col * sqsz
+ offset_x = graph_x + col * SQSZ
draw.line(
(offset_x, height + graph_y - 2, offset_x, height + graph_y + 2),
fill=0xAAAAAA,
@@ -223,12 +223,12 @@ def render(targets, histogram_period):
tmpdraw = ImageDraw.Draw(tmpim)
# Draw one annotation every four hours
- for col in range(0, width // sqsz + 1):
+ for col in range(0, width // SQSZ + 1):
# We're now at hour indicated by col
tstamp = t_hist_begin + timedelta(hours=col)
if tstamp.hour % 4 != 0:
continue
- offset_x = col * sqsz
+ offset_x = col * SQSZ
if tstamp.hour == 0:
tmpdraw.text(
(0, offset_x + 4), tstamp.strftime("%m-%d"), 0x333333, font=font
diff --git a/meshping/meshping/meshping/meshping_config.py b/meshping/meshping/meshping/meshping_config.py
index df3200f..1a7f18d 100644
--- a/meshping/meshping/meshping/meshping_config.py
+++ b/meshping/meshping/meshping/meshping_config.py
@@ -2,7 +2,7 @@
# TODO decide about option for config file, and automatic combination with docker
# environment variables
#
-# pylint: disable=too-many-instance-attributes
+# pylint: disable=too-many-instance-attributes,too-few-public-methods
class MeshpingConfig:
def __init__(self):
self.ping_timeout = 5
diff --git a/meshping/meshping/models.py b/meshping/meshping/models.py
index 254585a..9ec6ac8 100644
--- a/meshping/meshping/models.py
+++ b/meshping/meshping/models.py
@@ -8,6 +8,7 @@ class Target(models.Model):
addr = models.CharField(max_length=255, unique=True)
name = models.CharField(max_length=255)
+ # pylint: disable=too-few-public-methods
class Meta:
unique_together = ("addr", "name")
diff --git a/meshping/meshping/views.py b/meshping/meshping/views.py
index 5b17ef0..bd89400 100644
--- a/meshping/meshping/views.py
+++ b/meshping/meshping/views.py
@@ -1,3 +1,6 @@
+# We cannot rename view parameters as django spllies them as keyword arguments, so:
+# pylint: disable=unused-argument
+
import json
import os
import socket
@@ -58,6 +61,9 @@ def read_svg_file(icons_dir, filename):
# route /histogram//.png
+#
+# we cannot rename node to _node as django calls by keyword argument, so ignore this
+# pylint: disable=unused-argument
@require_http_methods(["GET"])
def histogram(request, node, target):
targets = []
@@ -89,8 +95,6 @@ def histogram(request, node, target):
# route /metrics
-#
-# TODO is the metrics output valid prometheus format when no values are present?
@require_http_methods(["GET"])
def metrics(request):
respdata = [