From 0c331bbb933b83dab5e876216a684851bf3cc964 Mon Sep 17 00:00:00 2001 From: retoor Date: Fri, 18 Jul 2025 00:43:37 +0200 Subject: [PATCH] Update. --- src/snek/app.py | 9 +++ src/snek/system/stats.py | 129 +++++++++++++++++++++++++++++++++++++++ src/snek/view/rpc.py | 6 +- 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/snek/system/stats.py diff --git a/src/snek/app.py b/src/snek/app.py index a46137b..73a0742 100644 --- a/src/snek/app.py +++ b/src/snek/app.py @@ -31,6 +31,7 @@ from snek.sgit import GitApplication from snek.sssh import start_ssh_server from snek.system import http from snek.system.cache import Cache +from snek.system.stats import middleware as stats_middleware, create_stats_structure, stats_handler from snek.system.markdown import MarkdownExtension from snek.system.middleware import auth_middleware, cors_middleware, csp_middleware from snek.system.profiler import profiler_handler @@ -127,6 +128,7 @@ async def trailing_slash_middleware(request, handler): class Application(BaseApplication): def __init__(self, *args, **kwargs): middlewares = [ + stats_middleware, cors_middleware, web.normalize_path_middleware(merge_slashes=True), ip2location_middleware, @@ -168,10 +170,16 @@ class Application(BaseApplication): self.ip2location = IP2Location.IP2Location( base_path.joinpath("IP2LOCATION-LITE-DB11.BIN") ) + self.on_startup.append(self.prepare_stats) self.on_startup.append(self.prepare_asyncio) self.on_startup.append(self.start_user_availability_service) self.on_startup.append(self.start_ssh_server) self.on_startup.append(self.prepare_database) + + async def prepare_stats(self, app): + app['stats'] = create_stats_structure() + print("Stats prepared", flush=True) + @property def uptime_seconds(self): @@ -308,6 +316,7 @@ class Application(BaseApplication): self.router.add_view("/drive.json", DriveApiView) self.router.add_view("/drive.html", DriveView) self.router.add_view("/drive/{drive}.json", DriveView) + self.router.add_get("/stats.html", stats_handler) self.router.add_view("/stats.json", StatsView) self.router.add_view("/user/{user}.html", UserView) self.router.add_view("/repository/{username}/{repository}", RepositoryView) diff --git a/src/snek/system/stats.py b/src/snek/system/stats.py new file mode 100644 index 0000000..c2148ff --- /dev/null +++ b/src/snek/system/stats.py @@ -0,0 +1,129 @@ +import asyncio +from aiohttp import web, WSMsgType + +from datetime import datetime, timedelta, timezone +from collections import defaultdict +import html + +def create_stats_structure(): + """Creates the nested dictionary structure for storing statistics.""" + def nested_dd(): + return defaultdict(lambda: defaultdict(int)) + return defaultdict(nested_dd) + +def get_time_keys(dt: datetime): + """Generates dictionary keys for different time granularities.""" + return { + "hour": dt.strftime('%Y-%m-%d-%H'), + "day": dt.strftime('%Y-%m-%d'), + "week": dt.strftime('%Y-%W'), # Week number, Monday is first day + "month": dt.strftime('%Y-%m'), + } + +def update_stats_counters(stats_dict: defaultdict, now: datetime): + """Increments the appropriate time-based counters in a stats dictionary.""" + keys = get_time_keys(now) + stats_dict['by_hour'][keys['hour']] += 1 + stats_dict['by_day'][keys['day']] += 1 + stats_dict['by_week'][keys['week']] += 1 + stats_dict['by_month'][keys['month']] += 1 + +def generate_time_series_svg(title: str, data: list[tuple[str, int]], y_label: str) -> str: + """Generates a responsive SVG bar chart for time-series data.""" + if not data: + return f"

{html.escape(title)}

No data yet.

" + max_val = max(item[1] for item in data) if data else 1 + svg_height, svg_width = 250, 600 + bar_padding = 5 + bar_width = (svg_width - 50) / len(data) - bar_padding + + bars = "" + labels = "" + for i, (key, val) in enumerate(data): + bar_height = (val / max_val) * (svg_height - 50) if max_val > 0 else 0 + x = i * (bar_width + bar_padding) + 40 + y = svg_height - bar_height - 30 + + bars += f'{html.escape(key)}: {val}' + labels += f'{html.escape(key)}' + + return f""" +

{html.escape(title)}

+
+ + {bars} + {labels} + + + 0 + {max_val} + +
+ """ + +@web.middleware +async def middleware(request, handler): + """Middleware to count all incoming HTTP requests.""" + # Avoid counting requests to the stats page itself + if request.path.startswith('/stats.html'): + return await handler(request) + + update_stats_counters(request.app['stats']['http_requests'], datetime.now(timezone.utc)) + return await handler(request) + +def update_websocket_stats(app): + update_stats_counters(app['stats']['websocket_requests'], datetime.now(timezone.utc)) + +async def pipe_and_count_websocket(ws_from, ws_to, stats_dict): + """This function proxies WebSocket messages AND counts them.""" + async for msg in ws_from: + # This is the key part for monitoring WebSockets + update_stats_counters(stats_dict, datetime.now(timezone.utc)) + + if msg.type == WSMsgType.TEXT: + await ws_to.send_str(msg.data) + elif msg.type == WSMsgType.BINARY: + await ws_to.send_bytes(msg.data) + elif msg.type in (WSMsgType.CLOSE, WSMsgType.ERROR): + await ws_to.close(code=ws_from.close_code) + break + + +async def stats_handler(request: web.Request): + """Handler to display the statistics dashboard.""" + stats = request.app['stats'] + now = datetime.now(timezone.utc) + + # Helper to prepare data for charts + def get_data(source, period, count): + data = [] + for i in range(count - 1, -1, -1): + if period == 'hour': + dt = now - timedelta(hours=i) + key, label = dt.strftime('%Y-%m-%d-%H'), dt.strftime('%H:00') + data.append((label, source['by_hour'].get(key, 0))) + elif period == 'day': + dt = now - timedelta(days=i) + key, label = dt.strftime('%Y-%m-%d'), dt.strftime('%a') + data.append((label, source['by_day'].get(key, 0))) + return data + + http_hourly = get_data(stats['http_requests'], 'hour', 24) + ws_hourly = get_data(stats['ws_messages'], 'hour', 24) + http_daily = get_data(stats['http_requests'], 'day', 7) + ws_daily = get_data(stats['ws_messages'], 'day', 7) + + body = f""" + App Stats + +

Application Dashboard

+

Last 24 Hours

+ {generate_time_series_svg("HTTP Requests", http_hourly, "Reqs/Hour")} + {generate_time_series_svg("WebSocket Messages", ws_hourly, "Msgs/Hour")} +

Last 7 Days

+ {generate_time_series_svg("HTTP Requests", http_daily, "Reqs/Day")} + {generate_time_series_svg("WebSocket Messages", ws_daily, "Msgs/Day")} + + """ + return web.Response(text=body, content_type='text/html') + diff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py index 224c360..1f7a80e 100644 --- a/src/snek/view/rpc.py +++ b/src/snek/view/rpc.py @@ -6,7 +6,7 @@ # MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions. - +from snek.system.stats import update_websocket_stats import asyncio import json import logging @@ -305,7 +305,7 @@ class RPCView(BaseView): async def send_message(self, channel_uid, message, is_final=True): self._require_login() - + message = message.strip() if not is_final: @@ -507,7 +507,9 @@ class RPCView(BaseView): raise Exception("Method not found") success = True try: + update_websocket_stats(self.app) result = await method(*args) + update_websocket_stats(self.app) except Exception as ex: result = {"exception": str(ex), "traceback": traceback.format_exc()} success = False