Compare commits

..
2 Commits
25 changed files with 201 additions and 260 deletions
+8 -13
View File
@@ -11,6 +11,7 @@ from core.constants import SOFTWARE_VERSION
from core.data_providers import DATA_PROVIDERS
from core.data_store import DATA_STORE
from core.prometheus_metrics_handler import alerts_gauge, memory_use_gauge, spots_gauge
from telnetserver.telnetserver import TELNET_SERVER
from webserver.webserver import WEB_SERVER
@@ -134,19 +135,13 @@ class StatusReporter:
else 0,
}
DATA_STORE.status.get()["webserver"] = {
"status": WEB_SERVER.web_server_metrics["status"],
"last_api_access": WEB_SERVER.web_server_metrics["last_api_access_time"]
.replace(tzinfo=pytz.UTC)
.timestamp()
if WEB_SERVER.web_server_metrics["last_api_access_time"]
else 0,
"api_access_count": WEB_SERVER.web_server_metrics["api_access_counter"],
"last_page_access": WEB_SERVER.web_server_metrics["last_page_access_time"]
.replace(tzinfo=pytz.UTC)
.timestamp()
if WEB_SERVER.web_server_metrics["last_page_access_time"]
else 0,
"page_access_count": WEB_SERVER.web_server_metrics["page_access_counter"],
"status": WEB_SERVER.web_server_metrics.status,
"api_requests_per_hour": WEB_SERVER.web_server_metrics.api_requests_per_hour(),
"page_requests_per_hour": WEB_SERVER.web_server_metrics.page_requests_per_hour(),
"sse_client_count": WEB_SERVER.sse_client_count,
}
DATA_STORE.status.get()["telnet"] = {
"client_count": TELNET_SERVER.client_count,
}
DATA_STORE.status.store()
+22 -9
View File
@@ -25,7 +25,9 @@ info:
* Added `contests_skip_max_duration_check` to alert query parameters
* SIG reference types (e.g. "Park") are now capitalised to match other enums
* Added the ability to get only certain fields of spots and alerts from the API by using the `fields` query parameter.
* Replace `last_page_access` with `page_requests_per_hour` and `last_api_access` with `api_requests_per_hour` in the web server stats.
* Added `sse_client_count` to the `webserver` stats, and a new `telnet` object with `client_count`.
### 2.0
* **Breaking change:** The "add spot" API has changed to enable future support for upstream submission to the spotting services associated with various SIGs. Instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot.
@@ -2100,14 +2102,25 @@ components:
type: string
description: The status of the web server
example: OK
last_page_access:
type: number
description: The last time a page was accessed on the web server, UTC seconds since UNIX epoch.
example: 1759579508
last_api_access:
type: number
description: The last time an API endpoint was accessed on the web server, UTC seconds since UNIX epoch.
example: 1759579508
page_requests_per_hour:
type: integer
description: The number of page requests handled by the web server in the last hour
example: 123
api_requests_per_hour:
type: integer
description: The number of API requests handled by the web server in the last hour
example: 123
sse_client_count:
type: integer
description: The number of clients currently connected to SSE streams
example: 5
"telnet":
type: object
properties:
client_count:
type: integer
description: The number of clients currently connected to the telnet server
example: 2
spot_providers:
type: array
description: An array of all the spot providers.
+4 -2
View File
@@ -9,8 +9,10 @@ function loadStatus() {
$("#total-alerts").text(jsonData["num_alerts"]);
$("#web-server-status").text(jsonData["webserver"]["status"]);
$("#web-server-last-api").text(moment.unix(jsonData["webserver"]["last_api_access"]).utc().fromNow());
$("#web-server-last-page").text(moment.unix(jsonData["webserver"]["last_page_access"]).utc().fromNow());
$("#web-server-api-rate").text(jsonData["webserver"]["api_requests_per_hour"] + " / hour");
$("#web-server-page-rate").text(jsonData["webserver"]["page_requests_per_hour"] + " / hour");
$("#web-server-sse-clients").text(jsonData["webserver"]["sse_client_count"]);
$("#telnet-server-clients").text(jsonData["telnet"]["client_count"]);
$("#cleanup-status").text(jsonData["cleanup"]["status"]);
$("#cleanup-last-ran").text((jsonData["cleanup"]["last_ran"] > 0) ? moment.unix(jsonData["cleanup"]["last_ran"]).utc().fromNow() : "N/A");
+44 -30
View File
@@ -21,21 +21,16 @@ class TelnetServer:
self._running = False
self._clients = set()
self._loop = None
self._shutdown_event = asyncio.Event()
def start(self, port=7373):
"""Starts the telnet server"""
self._port = port
# Start telnet server on the async loop
def run_loop():
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._start_internal())
self._loop.run_forever()
# Start the network thread as a daemon so it exits cleanly when the main script stops
t = threading.Thread(target=run_loop, daemon=True)
# Start the telnet server. asyncio.run() needs a coroutine, and threading.Thread needs a plain callable, so
# hand Thread the bridge between the two directly rather than writing a one-line wrapper method for it.
t = threading.Thread(target=asyncio.run, args=(self._start_internal(),), name="TelnetServer", daemon=True)
t.start()
logger.debug("Telnet server background thread spawned")
@@ -44,8 +39,16 @@ class TelnetServer:
self._running = True
async def _start_internal(self):
"""Start method (async). Sets up the telnet server and waits for shutdown."""
self._loop = asyncio.get_running_loop()
server = await asyncio.start_server(self._handle_client, "0.0.0.0", self._port)
logger.info(f"Telnet server listening on port {self._port}")
await asyncio.start_server(self._handle_client, "0.0.0.0", self._port)
async with server:
await self._shutdown_event.wait()
await self._stop_internal()
async def _handle_client(self, reader, writer):
"""Handles a new client connection"""
@@ -73,25 +76,12 @@ class TelnetServer:
if not data:
break
text = data.decode("ascii", errors="ignore")
for char in text:
if char in ("\r", "\n"):
# User pressed Enter, evaluate the command
command = input_buffer.strip().lower()
input_buffer = ""
if command == "exit":
writer.write(b"Goodbye!\r\n")
await writer.drain()
# Exit the while read loop, this will disconnect the client.
return
elif char in ("\b", "\x7f"):
# Handle backspaces
input_buffer = input_buffer[:-1]
else:
input_buffer += char
input_buffer, command = self._consume_input(input_buffer, data)
if command == "exit":
writer.write(b"Goodbye!\r\n")
await writer.drain()
# Exit the while read loop, this will disconnect the client.
return
except asyncio.CancelledError:
pass
@@ -106,9 +96,14 @@ class TelnetServer:
def stop(self):
"""Stops the telnet server"""
self._running = False
if self._loop and self._loop.is_running():
logger.debug("Stopping telnet server...")
self._loop.call_soon_threadsafe(self._loop.stop)
self._loop.call_soon_threadsafe(self._shutdown_event.set)
@property
def client_count(self) -> int:
return len(self._clients)
async def _stop_internal(self):
"""Stops the telnet server"""
@@ -146,6 +141,25 @@ class TelnetServer:
for writer in disconnected_clients:
self._clients.discard(writer)
@staticmethod
def _consume_input(input_buffer: str, data: bytes) -> tuple[str, str | None]:
"""Handle any input the user gives us, keeping a rolling buffer that we keep passing back through and
adding to. Once we get a command, return that as well, so the caller can deal with it."""
command = None
for char in data.decode("ascii", errors="ignore"):
if char in ("\r", "\n"):
stripped = input_buffer.strip().lower()
if stripped:
command = stripped
input_buffer = ""
elif char in ("\b", "\x7f"):
# Handle backspaces
input_buffer = input_buffer[:-1]
else:
input_buffer += char
return input_buffer, command
@staticmethod
def _format_dxspider_spot(spot: Spot) -> str:
"""Formats a spot into the format DXspider uses:
+1 -1
View File
@@ -77,7 +77,7 @@
</div>
<script src="/static/js/add-spot.js?v=1789140190"></script>
<script src="/static/js/add-spot.js?v=1789162324"></script>
<script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -83,7 +83,7 @@
</div>
<script src="/static/js/alerts.js?v=1789140190"></script>
<script src="/static/js/alerts.js?v=1789162324"></script>
<script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1789140190"></script>
<script src="/static/js/bands.js?v=1789140190"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789162324"></script>
<script src="/static/js/bands.js?v=1789162324"></script>
<script>$(document).ready(function () {
$("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1789140190" type="text/css">
<link rel="stylesheet" href="/static/css/style.css?v=1789162324" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -16,10 +16,10 @@
window.fetchEventSource = fetchEventSource;
</script>
<script src="/static/js/utils.js?v=1789140190"></script>
<script src="/static/js/ui-ham.js?v=1789140190"></script>
<script src="/static/js/geo.js?v=1789140190"></script>
<script src="/static/js/common.js?v=1789140190"></script>
<script src="/static/js/utils.js?v=1789162324"></script>
<script src="/static/js/ui-ham.js?v=1789162324"></script>
<script src="/static/js/geo.js?v=1789162324"></script>
<script src="/static/js/common.js?v=1789162324"></script>
{% end %}
{% block body %}
<div class="container">
+1 -1
View File
@@ -284,7 +284,7 @@
</div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1789140190"></script>
<script src="/static/js/conditions.js?v=1789162324"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
</script>
<script src="/static/js/spotsbandsandmap.js?v=1789140190"></script>
<script src="/static/js/map.js?v=1789140190"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789162323"></script>
<script src="/static/js/map.js?v=1789162323"></script>
<script>$(document).ready(function () {
$("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -125,8 +125,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1789140190"></script>
<script src="/static/js/spots.js?v=1789140190"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789162323"></script>
<script src="/static/js/spots.js?v=1789162323"></script>
<script>$(document).ready(function () {
$("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script>
+13 -3
View File
@@ -21,9 +21,19 @@
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>Web Server</strong></div>
<div class="col">Status: <span id="web-server-status"></span></div>
<div class="col">Last API call: <span id="web-server-last-api"></span></div>
<div class="col">Last page req: <span id="web-server-last-page"></span></div>
<div class="col">SSE clients connected: <span id="web-server-sse-clients"></span></div>
</div>
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"></div>
<div class="col">API request rate: <span id="web-server-api-rate"></span></div>
<div class="col">Page request rate: <span id="web-server-page-rate"></span></div>
</div>
{% if telnet_server_enabled %}
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
<div class="col"><strong>Telnet Server</strong></div>
<div class="col">Clients connected: <span id="telnet-server-clients"></span></div>
</div>
{% end %}
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
<div class="col"><strong>Cleanup Service</strong></div>
<div class="col">Status: <span id="cleanup-status"></span></div>
@@ -86,7 +96,7 @@
</div>
</div>
<script src="/static/js/status.js?v=1789140190"></script>
<script src="/static/js/status.js?v=1789162324"></script>
<script>
$(document).ready(function () {
$("#nav-link-status").addClass("active");
+1 -12
View File
@@ -1,10 +1,8 @@
import logging
import re
import threading
from datetime import datetime
from typing import Any
import pytz
import requests
import tornado
from tornado import httputil
@@ -12,7 +10,6 @@ from tornado.web import Application
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY
from core.constants import UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot
@@ -33,23 +30,15 @@ class APISpotHandler(tornado.web.RequestHandler):
**kwargs: Any,
):
self._spots = None
self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics, spot_providers=None):
def initialize(self, spots, spot_providers=None):
self._spots = spots
self._web_server_metrics = web_server_metrics
self._spot_providers = spot_providers or []
def post(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# Reject if not allowed
if not ALLOW_SPOTTING:
self.set_status(401)
+2 -19
View File
@@ -10,7 +10,6 @@ from tornado import httputil
from tornado.web import Application
from core.enums import AlertType
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials
@@ -27,12 +26,10 @@ class APIAlertsHandler(tornado.web.RequestHandler):
**kwargs: Any,
):
self._alerts = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, alerts, web_server_metrics):
def initialize(self, alerts):
self._alerts = alerts
self._web_server_metrics = web_server_metrics
@staticmethod
def _enrich(alerts, credentials):
@@ -45,12 +42,6 @@ class APIAlertsHandler(tornado.web.RequestHandler):
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
@@ -82,15 +73,13 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def __init__(self, application, request, **kwargs: Any):
self._sse_alert_broadcaster = None
self._web_server_metrics = None
self._query_params = None
self._credentials = None
self._fields = None
super().__init__(application, request, **kwargs)
def initialize(self, sse_alert_broadcaster, web_server_metrics):
def initialize(self, sse_alert_broadcaster):
self._sse_alert_broadcaster = sse_alert_broadcaster
self._web_server_metrics = web_server_metrics
def custom_headers(self):
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
@@ -99,12 +88,6 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def open(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
+1 -9
View File
@@ -11,7 +11,6 @@ from tornado.web import Application
from core.constants import BANDS
from core.enums import Continent
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
logger = logging.getLogger(__name__)
@@ -30,20 +29,13 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
**kwargs: Any,
):
self._spots = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics):
def initialize(self, spots):
self._spots = spots
self._web_server_metrics = web_server_metrics
def get(self):
try:
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
counts = Counter()
-33
View File
@@ -1,9 +1,7 @@
import logging
import re
from datetime import datetime
from typing import Any
import pytz
import tornado
from tornado import httputil
from tornado.web import Application
@@ -15,7 +13,6 @@ from core.geo_utils import (
lat_lon_to_cq_zone,
lat_lon_to_itu_zone,
)
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import get_ref_regex_for_sig
from core.utils import safe_json_dumps
@@ -34,20 +31,10 @@ class APILookupCallHandler(tornado.web.RequestHandler):
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, web_server_metrics):
self._web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
@@ -85,20 +72,10 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, web_server_metrics):
self._web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
@@ -143,20 +120,10 @@ class APILookupGridHandler(tornado.web.RequestHandler):
request: httputil.HTTPServerRequest,
**kwargs: Any,
):
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, web_server_metrics):
self._web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
+1 -12
View File
@@ -1,8 +1,6 @@
import logging
from datetime import datetime
from typing import Any
import pytz
import tornado
from tornado import httputil
from tornado.web import Application
@@ -10,7 +8,6 @@ from tornado.web import Application
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE
from core.constants import BANDS, PROPAGATION_MODES, SIGS
from core.enums import Continent, Mode, ModeType
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
logger = logging.getLogger(__name__)
@@ -26,23 +23,15 @@ class APIOptionsHandler(tornado.web.RequestHandler):
**kwargs: Any,
):
self._status_data = None
self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs)
def initialize(self, status_data, web_server_metrics, spot_providers=None):
def initialize(self, status_data, spot_providers=None):
self._status_data = status_data
self._web_server_metrics = web_server_metrics
self._spot_providers = spot_providers or []
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# Build a map of SIG name -> list of provider names that can submit spots for that SIG
spot_submit_providers = {}
+1 -12
View File
@@ -1,13 +1,10 @@
import logging
from datetime import datetime
from typing import Any
import pytz
import tornado
from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
logger = logging.getLogger(__name__)
@@ -23,21 +20,13 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
**kwargs: Any,
):
self._solar_conditions = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, solar_conditions, web_server_metrics):
def initialize(self, solar_conditions):
self._solar_conditions = solar_conditions
self._web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
self.write(self._solar_conditions.to_json())
self.set_status(200)
self.set_header("Cache-Control", "no-store")
+2 -19
View File
@@ -9,7 +9,6 @@ import tornado_eventsource.handler
from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials
@@ -26,12 +25,10 @@ class APISpotsHandler(tornado.web.RequestHandler):
**kwargs: Any,
):
self._spots = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics):
def initialize(self, spots):
self._spots = spots
self._web_server_metrics = web_server_metrics
@staticmethod
def _enrich(spots, credentials):
@@ -44,12 +41,6 @@ class APISpotsHandler(tornado.web.RequestHandler):
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
@@ -81,15 +72,13 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def __init__(self, application, request, **kwargs: Any):
self._sse_spot_broadcaster = None
self._web_server_metrics = None
self._query_params = None
self._credentials = None
self._fields = None
super().__init__(application, request, **kwargs)
def initialize(self, sse_spot_broadcaster, web_server_metrics):
def initialize(self, sse_spot_broadcaster):
self._sse_spot_broadcaster = sse_spot_broadcaster
self._web_server_metrics = web_server_metrics
def custom_headers(self):
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
@@ -100,12 +89,6 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""Called once on the client opening a connection, set things up"""
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
+1 -12
View File
@@ -1,13 +1,10 @@
import logging
from datetime import datetime
from typing import Any
import pytz
import tornado
from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
logger = logging.getLogger(__name__)
@@ -23,21 +20,13 @@ class APIStatusHandler(tornado.web.RequestHandler):
**kwargs: Any,
):
self._status_data = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, status_data, web_server_metrics):
def initialize(self, status_data):
self._status_data = status_data
self._web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
self.write(safe_json_dumps(self._status_data))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
+1 -12
View File
@@ -1,16 +1,13 @@
import logging
import re
from datetime import datetime
from typing import Any
import pytz
import tornado
from tornado import httputil
from tornado.web import Application
from core.config import ALLOW_SPOTTING
from core.constants import UNKNOWN_BAND
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.utils import infer_band_from_freq, safe_json_dumps
from data.spot import Spot
@@ -28,21 +25,13 @@ class V1APISpotHandler(tornado.web.RequestHandler):
**kwargs: Any,
):
self._spots = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics):
def initialize(self, spots):
self._spots = spots
self._web_server_metrics = web_server_metrics
def post(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# Reject if not allowed
if not ALLOW_SPOTTING:
self.set_status(401)
+1 -12
View File
@@ -1,7 +1,5 @@
from datetime import datetime
from typing import Any
import pytz
import tornado
from tornado import httputil
from tornado.web import Application
@@ -16,7 +14,6 @@ from core.config import (
WEB_UI_OPTIONS,
)
from core.constants import SOFTWARE_VERSION
from core.prometheus_metrics_handler import page_requests_counter
class PageTemplateHandler(tornado.web.RequestHandler):
@@ -29,20 +26,12 @@ class PageTemplateHandler(tornado.web.RequestHandler):
**kwargs: Any,
):
self._template_name = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, template_name, web_server_metrics):
def initialize(self, template_name):
self._template_name = template_name
self._web_server_metrics = web_server_metrics
def get(self):
# Metrics
self._web_server_metrics["last_page_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["page_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
page_requests_counter.inc()
# Load named template, and provide variables used in templates
self.render(
f"{self._template_name}.html",
+5
View File
@@ -26,6 +26,11 @@ class SSEBroadcaster:
with self._lock:
self._handlers.discard(handler)
@property
def client_count(self) -> int:
with self._lock:
return len(self._handlers)
def publish(self, value):
self._loop.add_callback(self._broadcast, value)
+36 -37
View File
@@ -33,6 +33,7 @@ from webserver.handlers.manifesthandler import ManifestHandler
from webserver.handlers.metrics import PrometheusMetricsHandler
from webserver.handlers.pagetemplate import PageTemplateHandler
from webserver.sse_broadcaster import SSEBroadcaster
from webserver.webserver_metrics import WebServerMetrics
logger = logging.getLogger(__name__)
@@ -52,19 +53,19 @@ class WebServer:
self._port = WEB_SERVER_PORT
self._api_only_mode = API_ONLY_MODE
self._shutdown_event = asyncio.Event()
self.web_server_metrics = {
"last_page_access_time": None,
"last_api_access_time": None,
"page_access_counter": 0,
"api_access_counter": 0,
"status": "Starting",
}
self.web_server_metrics = WebServerMetrics()
def setup(self):
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
@property
def sse_client_count(self) -> int:
"""Number of connected SSE clients, across both the spots and alerts streams."""
return self._spot_broadcaster.client_count + self._alert_broadcaster.client_count
def start(self):
"""Start the web server"""
@@ -82,62 +83,57 @@ class WebServer:
self._spot_broadcaster.bind_to_web_server_loop()
self._alert_broadcaster.bind_to_web_server_loop()
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
# to avoid copy-pasting the same thing to every route declaration below.
handler_opts = {"web_server_metrics": self.web_server_metrics}
# API endpoints are always enabled
api_routes = [
(
r"/api/v2/spots",
APISpotsHandler,
{"spots": self._data_store.spots, **handler_opts},
{"spots": self._data_store.spots},
),
(
r"/api/v2/alerts",
APIAlertsHandler,
{"alerts": self._data_store.alerts, **handler_opts},
{"alerts": self._data_store.alerts},
),
(
r"/api/v2/spots/stream",
APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts},
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v2/alerts/stream",
APIAlertsStreamHandler,
{"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts},
{"sse_alert_broadcaster": self._alert_broadcaster},
),
(
r"/api/v2/solar",
APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions.get(), **handler_opts},
{"solar_conditions": self._data_store.solar_conditions.get()},
),
(
r"/api/v2/dxstats",
APIDxStatsHandler,
{"spots": self._data_store.spots, **handler_opts},
{"spots": self._data_store.spots},
),
(
r"/api/v2/options",
APIOptionsHandler,
{"status_data": self._data_store.status.get(), **handler_opts},
{"status_data": self._data_store.status.get()},
),
(
r"/api/v2/status",
APIStatusHandler,
{"status_data": self._data_store.status.get(), **handler_opts},
{"status_data": self._data_store.status.get()},
),
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}),
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
(r"/api/v2/lookup/grid", APILookupGridHandler, {**handler_opts}),
(r"/api/v2/lookup/call", APILookupCallHandler),
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler),
(r"/api/v2/lookup/grid", APILookupGridHandler),
(
r"/api/v2/spot",
APISpotHandler,
{
"spots": self._data_store.spots,
"spot_providers": self._data_providers,
**handler_opts,
},
),
]
@@ -148,19 +144,18 @@ class WebServer:
(
r"/api/v1/spots",
V1APISpotsHandler,
{"spots": self._data_store.spots, **handler_opts},
{"spots": self._data_store.spots},
),
(
r"/api/v1/spots/stream",
V1APISpotsStreamHandler,
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts},
{"sse_spot_broadcaster": self._spot_broadcaster},
),
(
r"/api/v1/spot",
V1APISpotHandler,
{
"spots": self._data_store.spots,
**handler_opts,
},
),
(r"/api/v1/(.*)", V1RedirectHandler),
@@ -173,41 +168,41 @@ class WebServer:
(
r"/",
PageTemplateHandler,
{"template_name": "api_only_home", **handler_opts},
{"template_name": "api_only_home"},
)
]
else:
ui_routes = [
(r"/", PageTemplateHandler, {"template_name": "spots", **handler_opts}),
(r"/", PageTemplateHandler, {"template_name": "spots"}),
(
r"/map",
PageTemplateHandler,
{"template_name": "map", **handler_opts},
{"template_name": "map"},
),
(
r"/bands",
PageTemplateHandler,
{"template_name": "bands", **handler_opts},
{"template_name": "bands"},
),
(
r"/alerts",
PageTemplateHandler,
{"template_name": "alerts", **handler_opts},
{"template_name": "alerts"},
),
(
r"/conditions",
PageTemplateHandler,
{"template_name": "conditions", **handler_opts},
{"template_name": "conditions"},
),
(
r"/status",
PageTemplateHandler,
{"template_name": "status", **handler_opts},
{"template_name": "status"},
),
(
r"/about",
PageTemplateHandler,
{"template_name": "about", **handler_opts},
{"template_name": "about"},
),
]
# Only allow the Add Spot page if spotting is allowed
@@ -216,7 +211,7 @@ class WebServer:
(
r"/add-spot",
PageTemplateHandler,
{"template_name": "add_spot", **handler_opts},
{"template_name": "add_spot"},
)
]
@@ -226,7 +221,7 @@ class WebServer:
(
r"/apidocs",
PageTemplateHandler,
{"template_name": "apidocs", **handler_opts},
{"template_name": "apidocs"},
),
(r"/metrics", PrometheusMetricsHandler),
(r"/manifest.webmanifest", ManifestHandler),
@@ -254,7 +249,11 @@ class WebServer:
def request_log(handler):
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
web request logging altogetether."""
web request logging altogetether. Also records the time of the request and status in the webserver metrics. Probably
not what this method is supposed to be used for but it's a convenient thing that gets called on every request, so
saves having to pass the metrics around each handler individually."""
WEB_SERVER.web_server_metrics.record(handler.request.path, handler.get_status())
if LOG_WEB_REQUESTS:
if handler.get_status() < 500:
+44
View File
@@ -0,0 +1,44 @@
from collections import deque
from datetime import datetime, timedelta
import pytz
from core.prometheus_metrics_handler import api_requests_counter, page_requests_counter
class WebServerMetrics:
"""Tracker for web server metrics. Stores the times pages and API endpoints were accessed for an hour, so we
can display the rate of requests per hour, and also updates the equivalent Prometheus counters."""
def __init__(self):
self.status = "Starting"
self._page_access_times = deque()
self._api_access_times = deque()
def record(self, path: str, status_code: int):
"""Records data for a request, depending on whether it's a page, API, or other request, and making sure
the response code isn't 404. Also sets the status of the web server."""
if status_code == 404 or path.startswith(("/static/", "/metrics", "/manifest.webmanifest")):
return
self.status = "OK" if status_code < 500 else "Error"
if path.startswith("/api/"):
api_requests_counter.inc()
self._api_access_times.append(datetime.now(pytz.UTC))
else:
page_requests_counter.inc()
self._page_access_times.append(datetime.now(pytz.UTC))
def page_requests_per_hour(self) -> int:
return self._count_and_prune_last_hour(self._page_access_times)
def api_requests_per_hour(self) -> int:
return self._count_and_prune_last_hour(self._api_access_times)
@staticmethod
def _count_and_prune_last_hour(access_times: deque) -> int:
cutoff = datetime.now(pytz.UTC) - timedelta(hours=1)
while access_times and access_times[0] < cutoff:
access_times.popleft()
return len(access_times)