Merge pull request #54 from craigerl/stats-web-ui

Added aprsd web index page
This commit is contained in:
Walter A. Boring IV 2021-04-02 12:00:55 -04:00 committed by GitHub
commit cabe374909
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 626 additions and 32 deletions

View File

@ -5,6 +5,9 @@ APRSD
.. image:: https://badge.fury.io/py/aprsd.svg
:target: https://badge.fury.io/py/aprsd
.. image:: http://hits.dwyl.com/craigerl/aprsd.svg
:target: http://hits.dwyl.com/craigerl/aprsd
.. image:: https://github.com/craigerl/aprsd/workflows/python/badge.svg
:target: https://github.com/craigerl/aprsd/actions

39
aprsd-lnav.json Normal file
View File

@ -0,0 +1,39 @@
{
"aprsd" : {
"title" : "APRSD APRS-IS server log format",
"description" : "Log formats used by ARPRSD server",
"url" : "http://github.com/craigerl/aprsd",
"regex" : {
"std" : {
"pattern" : "^\\[(?<timestamp>\\d{2}\\/\\d{2}\\/\\d{4} \\d{2}:\\d{2}:\\d{2} ([AaPp][Mm]))\\] \\[(?<thread>\\w+\\s*)\\] \\[(?<alert_level>\\w+\\s*)\\] (?<body>([^-]*)-*)\\s\\[(?<file>([^:]*))\\:(?<line>\\d+)\\]"
}
},
"level-field" : "alert_level",
"level" : {
"info" : "INFO",
"error" : "ERROR",
"warning" : "WARN",
"debug" : "DEBUG",
"fatal" : "FATAL",
"info" : "UNKNOWN"
},
"value" : {
"alert_level": { "kind" : "string", "identifier" : true },
"thread": { "kind" : "string", "identifier" : true },
"body" : { "kind" : "string" },
"file" : { "kind" : "string" }
},
"timestamp-field" : "timestamp",
"timestamp-format" : [
"%m/%d/%Y %I:%M:%S %p"
],
"sample" : [
{
"line" : "[03/30/2021 08:57:44 PM] [MainThread ] [INFO ] Skipping Custom Plugins directory. - [/home/waboring/devel/aprsd/aprsd/plugin.py:232]"
},
{
"line" : "[03/30/2021 08:57:44 PM] [KeepAlive ] [DEBUG] Uptime (0:00:00.577754) Tracker(0) Msgs: TX:0 RX:0 EmailThread: N/A RAM: Current:50289 Peak:99697 - [/home/waboring/devel/aprsd/aprsd/threads.py:89]"
}
]
}
}

View File

@ -3,9 +3,17 @@ import select
import time
import aprsd
from aprsd import stats
import aprslib
from aprslib import is_py3
from aprslib.exceptions import LoginError
from aprslib.exceptions import (
ConnectionDrop,
ConnectionError,
GenericError,
LoginError,
ParseError,
UnknownFormat,
)
LOG = logging.getLogger("APRSD")
@ -18,6 +26,7 @@ class Client:
config = None
connected = False
server_string = None
def __new__(cls, *args, **kwargs):
"""This magic turns this into a singleton."""
@ -153,7 +162,16 @@ class Aprsdis(aprslib.IS):
self.logger.debug("Server: %s", test)
_, _, callsign, status, _ = test.split(" ", 4)
a, b, callsign, status, e = test.split(" ", 4)
s = e.split(",")
if len(s):
server_string = s[0].replace("server ", "")
else:
server_string = e.replace("server ", "")
self.logger.info("Connected to {}".format(server_string))
self.server_string = server_string
stats.APRSDStats().set_aprsis_server(server_string)
if callsign == "":
raise LoginError("Server responded with empty callsign???")
@ -171,11 +189,67 @@ class Aprsdis(aprslib.IS):
self.logger.error(str(e))
self.close()
raise
except Exception:
except Exception as e:
self.close()
self.logger.error("Failed to login")
self.logger.error("Failed to login '{}'".format(e))
raise LoginError("Failed to login")
def consumer(self, callback, blocking=True, immortal=False, raw=False):
"""
When a position sentence is received, it will be passed to the callback function
blocking: if true (default), runs forever, otherwise will return after one sentence
You can still exit the loop, by raising StopIteration in the callback function
immortal: When true, consumer will try to reconnect and stop propagation of Parse exceptions
if false (default), consumer will return
raw: when true, raw packet is passed to callback, otherwise the result from aprs.parse()
"""
if not self._connected:
raise ConnectionError("not connected to a server")
line = b""
while True:
try:
for line in self._socket_readlines(blocking):
if line[0:1] != b"#":
if raw:
callback(line)
else:
callback(self._parse(line))
else:
self.logger.debug("Server: %s", line.decode("utf8"))
stats.APRSDStats().set_aprsis_keepalive()
except ParseError as exp:
self.logger.log(11, "%s\n Packet: %s", exp.args[0], exp.args[1])
except UnknownFormat as exp:
self.logger.log(9, "%s\n Packet: %s", exp.args[0], exp.args[1])
except LoginError as exp:
self.logger.error("%s: %s", exp.__class__.__name__, exp.args[0])
except (KeyboardInterrupt, SystemExit):
raise
except (ConnectionDrop, ConnectionError):
self.close()
if not immortal:
raise
else:
self.connect(blocking=blocking)
continue
except GenericError:
pass
except StopIteration:
break
except Exception:
self.logger.error("APRS Packet: %s", line)
raise
if not blocking:
break
def get_client():
cl = Client()

View File

@ -1,8 +1,11 @@
import datetime
import json
import logging
from logging import NullHandler
from logging.handlers import RotatingFileHandler
import sys
import aprsd
from aprsd import messaging, plugin, stats
from aprsd import messaging, plugin, stats, utils
import flask
import flask_classful
from flask_httpauth import HTTPBasicAuth
@ -39,9 +42,14 @@ class APRSDFlask(flask_classful.FlaskView):
users = self.users
@auth.login_required
def index(self):
return "Hello"
# return flask.render_template("index.html", message=msg)
stats = self._stats()
return flask.render_template(
"index.html",
initial_stats=stats,
callsign=self.config["aprs"]["login"],
)
@auth.login_required
def messages(self):
@ -67,29 +75,71 @@ class APRSDFlask(flask_classful.FlaskView):
track.save()
return json.dumps({"messages": "saved"})
def stats(self):
def _stats(self):
stats_obj = stats.APRSDStats()
track = messaging.MsgTrack()
now = datetime.datetime.now()
time_format = "%m-%d-%Y %H:%M:%S"
stats_dict = stats_obj.stats()
result = {
"version": aprsd.__version__,
"uptime": stats_obj.uptime,
"time": now.strftime(time_format),
"size_tracker": len(track),
"stats": stats_obj.stats(),
"stats": stats_dict,
}
return json.dumps(result)
return result
def stats(self):
return json.dumps(self._stats())
def init_flask(config):
def setup_logging(config, flask_app, loglevel, quiet):
flask_log = logging.getLogger("werkzeug")
if not config["aprsd"]["web"].get("logging_enabled", False):
# disable web logging
flask_log.disabled = True
flask_app.logger.disabled = True
return
log_level = utils.LOG_LEVELS[loglevel]
LOG.setLevel(log_level)
log_format = config["aprsd"].get("logformat", utils.DEFAULT_LOG_FORMAT)
date_format = config["aprsd"].get("dateformat", utils.DEFAULT_DATE_FORMAT)
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
log_file = config["aprsd"].get("logfile", None)
if log_file:
fh = RotatingFileHandler(log_file, maxBytes=(10248576 * 5), backupCount=4)
else:
fh = NullHandler()
fh.setFormatter(log_formatter)
for handler in flask_app.logger.handlers:
handler.setFormatter(log_formatter)
print(handler)
flask_log.addHandler(fh)
if not quiet:
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(log_formatter)
flask_log.addHandler(sh)
def init_flask(config, loglevel, quiet):
flask_app = flask.Flask(
"aprsd",
static_url_path="",
static_folder="web/static",
template_folder="web/templates",
)
setup_logging(config, flask_app, loglevel, quiet)
server = APRSDFlask()
server.set_config(config)
# flask_app.route('/', methods=['GET'])(server.index)
flask_app.route("/", methods=["GET"])(server.index)
flask_app.route("/stats", methods=["GET"])(server.stats)
flask_app.route("/messages", methods=["GET"])(server.messages)
flask_app.route("/save", methods=["GET"])(server.save)

View File

@ -215,6 +215,15 @@ def check(loglevel, config_file, health_url, timeout):
LOG.error("Email thread is very old! {}".format(d))
sys.exit(-1)
aprsis_last_update = stats["stats"]["aprs-is"]["last_update"]
delta = parse_delta_str(aprsis_last_update)
d = datetime.timedelta(**delta)
max_timeout = {"hours": 0.0, "minutes": 5, "seconds": 0}
max_delta = datetime.timedelta(**max_timeout)
if d > max_delta:
LOG.error("APRS-IS last update is very old! {}".format(d))
sys.exit(-1)
sys.exit(0)

View File

@ -42,13 +42,6 @@ import click_completion
# logging.basicConfig(level=logging.DEBUG) # level=10
LOG = logging.getLogger("APRSD")
LOG_LEVELS = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
}
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@ -175,7 +168,7 @@ def signal_handler(sig, frame):
# to disable logging to stdout, but still log to file
# use the --quiet option on the cmdln
def setup_logging(config, loglevel, quiet):
log_level = LOG_LEVELS[loglevel]
log_level = utils.LOG_LEVELS[loglevel]
LOG.setLevel(log_level)
log_format = config["aprsd"].get("logformat", utils.DEFAULT_LOG_FORMAT)
date_format = config["aprsd"].get("dateformat", utils.DEFAULT_DATE_FORMAT)
@ -485,7 +478,7 @@ def server(
if web_enabled:
flask_enabled = True
app = flask.init_flask(config)
app = flask.init_flask(config, loglevel, quiet)
app.run(
host=config["aprsd"]["web"]["host"],
port=config["aprsd"]["web"]["port"],

View File

@ -2,6 +2,9 @@ import datetime
import logging
import threading
import aprsd
from aprsd import utils
LOG = logging.getLogger("APRSD")
@ -12,6 +15,7 @@ class APRSDStats:
config = None
start_time = None
_aprsis_keepalive = None
_msgs_tracked = 0
_msgs_tx = 0
@ -26,12 +30,16 @@ class APRSDStats:
_email_tx = 0
_email_rx = 0
_mem_current = 0
_mem_peak = 0
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
# any initializetion here
cls._instance.lock = threading.Lock()
cls._instance.start_time = datetime.datetime.now()
cls._instance._aprsis_keepalive = datetime.datetime.now()
return cls._instance
def __init__(self, config=None):
@ -43,6 +51,42 @@ class APRSDStats:
with self.lock:
return str(datetime.datetime.now() - self.start_time)
@property
def memory(self):
with self.lock:
return self._mem_current
def set_memory(self, memory):
with self.lock:
self._mem_current = memory
@property
def memory_peak(self):
with self.lock:
return self._mem_peak
def set_memory_peak(self, memory):
with self.lock:
self._mem_peak = memory
@property
def aprsis_server(self):
with self.lock:
return self._aprsis_server
def set_aprsis_server(self, server):
with self.lock:
self._aprsis_server = server
@property
def aprsis_keepalive(self):
with self.lock:
return self._aprsis_keepalive
def set_aprsis_keepalive(self):
with self.lock:
self._aprsis_keepalive = datetime.datetime.now()
@property
def msgs_tx(self):
with self.lock:
@ -126,7 +170,30 @@ class APRSDStats:
def stats(self):
now = datetime.datetime.now()
if self._email_thread_last_time:
last_update = str(now - self._email_thread_last_time)
else:
last_update = "never"
if self._aprsis_keepalive:
last_aprsis_keepalive = str(now - self._aprsis_keepalive)
else:
last_aprsis_keepalive = "never"
stats = {
"aprsd": {
"version": aprsd.__version__,
"uptime": self.uptime,
"memory_current": self.memory,
"memory_current_str": utils.human_size(self.memory),
"memory_peak": self.memory_peak,
"memory_peak_str": utils.human_size(self.memory_peak),
},
"aprs-is": {
"server": self.aprsis_server,
"callsign": self.config["aprs"]["login"],
"last_update": last_aprsis_keepalive,
},
"messages": {
"tracked": self.msgs_tracked,
"sent": self.msgs_tx,
@ -136,9 +203,10 @@ class APRSDStats:
"mic-e recieved": self.msgs_mice_rx,
},
"email": {
"enabled": self.config["aprsd"]["email"]["enabled"],
"sent": self._email_tx,
"recieved": self._email_rx,
"thread_last_update": str(now - self._email_thread_last_time),
"thread_last_update": last_update,
},
}
return stats

View File

@ -1,12 +1,13 @@
import abc
import datetime
import gc
import logging
import queue
import threading
import time
import tracemalloc
from aprsd import client, messaging, plugin, stats, trace
from aprsd import client, messaging, plugin, stats, trace, utils
import aprslib
LOG = logging.getLogger("APRSD")
@ -74,26 +75,33 @@ class KeepAliveThread(APRSDThread):
def loop(self):
if self.cntr % 6 == 0:
nuked = gc.collect()
tracker = messaging.MsgTrack()
stats_obj = stats.APRSDStats()
now = datetime.datetime.now()
last_email = stats.APRSDStats().email_thread_time
last_email = stats_obj.email_thread_time
if last_email:
email_thread_time = str(now - last_email)
else:
email_thread_time = "N/A"
last_msg_time = str(now - stats_obj.aprsis_keepalive)
current, peak = tracemalloc.get_traced_memory()
stats_obj.set_memory(current)
stats_obj.set_memory_peak(peak)
LOG.debug(
"Uptime ({}) Tracker({}) "
"Msgs: TX:{} RX:{} EmailThread: {} RAM: Current:{} Peak:{}".format(
"Msgs: TX:{} RX:{} Last: {} - EmailThread: {} - RAM: Current:{} Peak:{} Nuked: {}".format(
stats_obj.uptime,
len(tracker),
stats_obj.msgs_tx,
stats_obj.msgs_rx,
last_msg_time,
email_thread_time,
current,
peak,
utils.human_size(current),
utils.human_size(peak),
nuked,
),
)
self.cntr += 1

View File

@ -2,6 +2,7 @@
import errno
import functools
import logging
import os
from pathlib import Path
import sys
@ -11,9 +12,17 @@ from aprsd import plugin
import click
import yaml
LOG_LEVELS = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
}
DEFAULT_LOG_FORMAT = (
"[%(asctime)s] [%(threadName)-12s] [%(levelname)-5.5s]"
" %(message)s - [%(pathname)s.%(funcName)s:%(lineno)d]"
" %(message)s - [%(pathname)s:%(lineno)d]"
)
DEFAULT_DATE_FORMAT = "%m/%d/%Y %I:%M:%S %p"
@ -37,6 +46,7 @@ DEFAULT_CONFIG_DICT = {
"units": "imperial",
"web": {
"enabled": True,
"logging_enabled": True,
"host": "0.0.0.0",
"port": 8001,
"users": {
@ -351,3 +361,10 @@ def parse_config(config_file):
)
return config
def human_size(bytes, units=None):
""" Returns a human readable string representation of bytes """
if not units:
units = [" bytes", "KB", "MB", "GB", "TB", "PB", "EB"]
return str(bytes) + units[0] if bytes < 1024 else human_size(bytes >> 10, units[1:])

View File

@ -1,4 +1,337 @@
<html>
<head>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<body><h1>{{ message }}</h1></body>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.23.0/prism.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.23.0/components/prism-json.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.23.0/themes/prism-tomorrow.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.bundle.js"></script>
<script type="text/javascript"">
var initial_stats = {{ initial_stats|tojson|safe }};
var memory_chart = null
var message_chart = null
var color = Chart.helpers.color;
window.chartColors = {
red: 'rgb(255, 99, 132)',
orange: 'rgb(255, 159, 64)',
yellow: 'rgb(255, 205, 86)',
green: 'rgb(26, 181, 77)',
blue: 'rgb(54, 162, 235)',
purple: 'rgb(153, 102, 255)',
grey: 'rgb(201, 203, 207)',
black: 'rgb(0, 0, 0)'
};
function start_charts() {
Chart.scaleService.updateScaleDefaults('linear', {
ticks: {
min: 0
}
});
memory_chart = new Chart($("#memChart"), {
label: 'Memory Usage',
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Peak Ram usage',
borderColor: window.chartColors.red,
data: [],
},
{
label: 'Current Ram usage',
borderColor: window.chartColors.blue,
data: [],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
title: {
display: true,
text: 'Memory Usage',
},
scales: {
x: {
type: 'timeseries',
offset: true,
ticks: {
major: { enabled: true },
fontStyle: context => context.tick.major ? 'bold' : undefined,
source: 'data',
maxRotation: 0,
autoSkip: true,
autoSkipPadding: 75,
}
}
}
}
});
message_chart = new Chart($("#messageChart"), {
label: 'Messages',
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Messages Sent',
borderColor: window.chartColors.green,
data: [],
},
{
label: 'Messages Recieved',
borderColor: window.chartColors.yellow,
data: [],
},
{
label: 'Ack Sent',
borderColor: window.chartColors.purple,
data: [],
},
{
label: 'Ack Recieved',
borderColor: window.chartColors.black,
data: [],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
title: {
display: true,
text: 'APRS Messages',
},
scales: {
x: {
type: 'timeseries',
offset: true,
ticks: {
major: { enabled: true },
fontStyle: context => context.tick.major ? 'bold' : undefined,
source: 'data',
maxRotation: 0,
autoSkip: true,
autoSkipPadding: 75,
}
}
}
}
});
email_chart = new Chart($("#emailChart"), {
label: 'Email Messages',
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Sent',
borderColor: window.chartColors.green,
data: [],
},
{
label: 'Recieved',
borderColor: window.chartColors.yellow,
data: [],
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
title: {
display: true,
text: 'Email Messages',
},
scales: {
x: {
type: 'timeseries',
offset: true,
ticks: {
major: { enabled: true },
fontStyle: context => context.tick.major ? 'bold' : undefined,
source: 'data',
maxRotation: 0,
autoSkip: true,
autoSkipPadding: 75,
}
}
}
}
});
}
function addData(chart, label, newdata) {
chart.data.labels.push(label);
chart.data.datasets.forEach((dataset) => {
dataset.data.push(newdata);
});
chart.update();
}
function updateDualData(chart, label, first, second) {
chart.data.labels.push(label);
chart.data.datasets[0].data.push(first);
chart.data.datasets[1].data.push(second);
chart.update();
}
function updateQuadData(chart, label, first, second, third, fourth) {
chart.data.labels.push(label);
chart.data.datasets[0].data.push(first);
chart.data.datasets[1].data.push(second);
chart.data.datasets[2].data.push(third);
chart.data.datasets[3].data.push(fourth);
chart.update();
}
function update_stats( data ) {
$("#version").text( data["stats"]["aprsd"]["version"] );
$("#aprsis").text( "APRS-IS Server: " + data["stats"]["aprs-is"]["server"] );
$("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] );
const html_pretty = Prism.highlight(JSON.stringify(data, null, '\t'), Prism.languages.json, 'json');
$("#jsonstats").html(html_pretty);
short_time = data["time"].split(/\s(.+)/)[1];
updateQuadData(message_chart, short_time, data["stats"]["messages"]["sent"], data["stats"]["messages"]["recieved"], data["stats"]["messages"]["ack_sent"], data["stats"]["messages"]["ack_recieved"]);
updateDualData(email_chart, short_time, data["stats"]["email"]["sent"], data["stats"]["email"]["recieved"]);
updateDualData(memory_chart, short_time, data["stats"]["aprsd"]["memory_peak"], data["stats"]["aprsd"]["memory_current"]);
}
function start_update() {
(function statsworker() {
$.ajax({
url: "/stats",
type: 'GET',
dataType: 'json',
success: function(data) {
update_stats(data);
},
complete: function() {
setTimeout(statsworker, 10000);
}
});
})();
}
$(document).ready(function() {
console.log(initial_stats);
start_update();
start_charts();
$("#toggleStats").click(function() {
$("#jsonstats").fadeToggle(1000);
});
});
</script>
<style type="text/css">
body {
display: grid;
grid-template-rows: auto 1fr auto;
background: #eeeeee;
margin: 2em;
padding: 0;
text-align: center;
font-family: system-ui, sans-serif;
height: 100vh;
}
header {
padding: 2em;
height: 10vh;
}
#main {
padding: 2em;
height: 80vh;
}
footer {
padding: 2em;
text-align: center;
height: 10vh;
}
#graphs {
display: grid;
width: 100%;
height: 300px;
grid-template-columns: 1fr 1fr;
}
#graphs_center {
display: block;
margin-top: 10px;
margin-bottom: 10px;
width: 100%;
height: 300px;
}
#left {
margin-right: 2px;
height: 300px;
}
#right {
height: 300px;
}
#center {
height: 300px;
}
#messageChart, #emailChart, #memChart {
border: 1px solid #ccc;
background: #ddd;
}
#stats {
margin: auto;
width: 80%;
}
#jsonstats {
display: none;
}
#title {
font-size: 4em;
}
#uptime, #aprsis {
font-size: 1em;
}
#callsign {
font-size: 1.4em;
color: #00F;
}
</style>
</head>
<body>
<header>
<div id="title">APRSD version <span id="version"></div></div>
<div id="callsign">{{ callsign }}</div>
<div id="aprsis"></div>
<div id="uptime"></div>
</header>
<div id="main">
<div id="graphs">
<div id="left"><canvas id="messageChart"></canvas></div>
<div id="right"><canvas class="right" id="emailChart"></canvas></div>
</div>
<div id="graphs_center">
<div id="center"><canvas id="memChart"></canvas></div>
</div>
<div id="stats">
<button id="toggleStats">Toggle raw json</button>
<pre id="jsonstats" class="language-json">{{ stats }}</pre>
</div>
</div>
<footer>
<a href="https://badge.fury.io/py/aprsd"><img src="https://badge.fury.io/py/aprsd.svg" alt="PyPI version" height="18"></a>
<a href="https://github.com/craigerl/aprsd"><img src="https://img.shields.io/badge/Made%20with-Python-1f425f.svg" height="18"></a>
</footer>
</body>
</html>