diff --git a/README.rst b/README.rst index 0401272..f7ea08e 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/aprsd-lnav.json b/aprsd-lnav.json new file mode 100644 index 0000000..de0d4a9 --- /dev/null +++ b/aprsd-lnav.json @@ -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" : "^\\[(?\\d{2}\\/\\d{2}\\/\\d{4} \\d{2}:\\d{2}:\\d{2} ([AaPp][Mm]))\\] \\[(?\\w+\\s*)\\] \\[(?\\w+\\s*)\\] (?([^-]*)-*)\\s\\[(?([^:]*))\\:(?\\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]" + } + ] + } +} diff --git a/aprsd/client.py b/aprsd/client.py index 81152ec..92e9c98 100644 --- a/aprsd/client.py +++ b/aprsd/client.py @@ -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() diff --git a/aprsd/flask.py b/aprsd/flask.py index d6136b2..2f0eee6 100644 --- a/aprsd/flask.py +++ b/aprsd/flask.py @@ -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) diff --git a/aprsd/healthcheck.py b/aprsd/healthcheck.py index db2f97e..35e3a6e 100644 --- a/aprsd/healthcheck.py +++ b/aprsd/healthcheck.py @@ -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) diff --git a/aprsd/main.py b/aprsd/main.py index 2bf476f..119a9a6 100644 --- a/aprsd/main.py +++ b/aprsd/main.py @@ -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"], diff --git a/aprsd/stats.py b/aprsd/stats.py index 30c9f5a..6729564 100644 --- a/aprsd/stats.py +++ b/aprsd/stats.py @@ -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 diff --git a/aprsd/threads.py b/aprsd/threads.py index 736a052..01439ee 100644 --- a/aprsd/threads.py +++ b/aprsd/threads.py @@ -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 diff --git a/aprsd/utils.py b/aprsd/utils.py index c101fe5..5373fa0 100644 --- a/aprsd/utils.py +++ b/aprsd/utils.py @@ -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:]) diff --git a/aprsd/web/templates/index.html b/aprsd/web/templates/index.html index dc09bc0..382cde0 100644 --- a/aprsd/web/templates/index.html +++ b/aprsd/web/templates/index.html @@ -1,4 +1,337 @@ + + + + -

{{ message }}

+ + + + + + + + + + + + + +
+
APRSD version
+
{{ callsign }}
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+ +
{{ stats }}
+
+
+ + +