Add Memory Analyzer #8

Merged
Typhus merged 1 commits from feature/memory-analyser into main 2026-08-24 16:51:05 +00:00
9 changed files with 267 additions and 8 deletions
+4 -1
View File
@@ -1,7 +1,7 @@
# CONFIG
*.toml
# Chatch-all default Python gitignore
# Catch-all default Python gitignore
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -152,3 +152,6 @@ projects.json
# Authentication directoy
auth/
# Backup Files
*~
+3
View File
@@ -50,6 +50,9 @@
default_profile_picture="src/elm/assets/img/anon.png"
dev_mode_active=true # Supresses E-Mail sending, activates PayPal sandbox API
[debug]
tracemalloc_enabled=false
[paypal]
client_id_sandbox=""
secret_sandbox=""
+1 -1
View File
@@ -10,7 +10,7 @@ services:
- "8000:8000"
- "8001:8001"
volumes:
- ./:/opt/elm
- ./:/opt/elm:z
entrypoint: ["/bin/sh", "-c", "cd /opt/elm/src && rio run --release --public --port 8000"]
db:
+10 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import tracemalloc
from pathlib import Path
from uuid import uuid4
@@ -8,7 +9,7 @@ from from_root import from_root
from rio import App, Theme, Color, Font, Icon, Session
from elm.components.RootComponent import RootComponent
from elm.services import ConfigurationService, DatabaseService, UserService, LocalData, LocalDataService, MailingService, AccountingService, PreloadService, ReceiptPrintingService
from elm.services import ConfigurationService, DatabaseService, UserService, LocalData, LocalDataService, MailingService, AccountingService, PreloadService, ReceiptPrintingService, MemoryService
from elm.types import UserSession
logger = logging.getLogger("ELM")
@@ -51,6 +52,13 @@ database_service = DatabaseService(configuration_service.get_database_configurat
mailing_service = MailingService(configuration_service)
lan_info = configuration_service.get_lan_info()
tracemalloc_enabled = configuration_service.TRACEMALLOC_ENABLED
if tracemalloc_enabled:
tracemalloc.start(10)
memory_service = MemoryService(tracemalloc_enabled)
def is_mobile(self: Session) -> bool:
return self.screen_width < self.theme.MAX_MOBILE_SCREEN_WIDTH
@@ -79,7 +87,7 @@ app = App(
theme=theme,
assets_dir=Path(__file__).parent / "assets",
build=RootComponent,
default_attachments=[LocalData(), configuration_service, database_service, UserService(), LocalDataService(), mailing_service, AccountingService(configuration_service, mailing_service), PreloadService(), ReceiptPrintingService(configuration_service.get_receipt_printing_configuration(), configuration_service.DEV_MODE_ACTIVE)],
default_attachments=[LocalData(), configuration_service, database_service, UserService(), LocalDataService(), mailing_service, AccountingService(configuration_service, mailing_service), PreloadService(), ReceiptPrintingService(configuration_service.get_receipt_printing_configuration(), configuration_service.DEV_MODE_ACTIVE), memory_service],
on_app_start=on_app_start,
on_session_start=on_session_start,
icon=from_root("src/elm/assets/img/favicon.png"),
+7 -4
View File
@@ -5,7 +5,7 @@ from typing import Callable
from rio import Component, Row, Column, Color, PointerEventListener, PointerEvent, Rectangle, Text, TextStyle, event
from elm.types import UserSession
from elm.services import AccountingService
from elm.services import AccountingService, MemoryService
class UserNavigationButton(Component):
@@ -103,10 +103,13 @@ class UserNavigation(Component):
]
if self.show_admin_navigation():
base_nav.extend([
admin_buttons = [
UserNavigationButton("Admin: Benutzer", "/user-admin", self.close_navigation),
UserNavigationButton("Admin: Catering", "/catering-admin", self.close_navigation)
])
UserNavigationButton("Admin: Catering", "/catering-admin", self.close_navigation),
]
if self.session[MemoryService].is_enabled():
admin_buttons.append(UserNavigationButton("Admin: Speicher", "/memory-admin", self.close_navigation))
base_nav.extend(admin_buttons)
base_nav.append(
UserNavigationButton("Ausloggen", "/logout", self.close_navigation)
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
import logging
from typing import Optional
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, GuardEvent, PointerEventListener, PointerEvent, Set
from rio.event import on_populate
from elm.types import UserSession
from elm.services import MemoryService
from elm.components import ElmButton
logger = logging.getLogger(__name__.split(".")[-1])
def memory_analyzer_page_guard(event: GuardEvent) -> Optional[str]:
try:
if event.session[UserSession].is_team_member:
return None
return "/"
except KeyError:
return "/"
@page(name="Speicheranalyse", url_segment="memory-admin", guard=memory_analyzer_page_guard)
class MemoryAnalyzerPage(Component):
diff_entries: list = []
has_baseline: bool = False
is_loading: bool = True
expanded_ids: Set[int] = Set()
@staticmethod
def _format_size(size_bytes: int) -> str:
if size_bytes >= 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.2f} MiB"
if size_bytes >= 1024:
return f"{size_bytes / 1024:.2f} KiB"
return f"{size_bytes} B"
@on_populate
async def on_populate(self) -> None:
mem_service = self.session[MemoryService]
self.has_baseline = mem_service.get_baseline() is not None
if self.has_baseline:
self.diff_entries = sorted(mem_service.get_diff(), key=lambda s: abs(s.size_diff), reverse=True)[:50]
self.is_loading = False
async def refresh(self) -> None:
mem_service = self.session[MemoryService]
self.has_baseline = mem_service.get_baseline() is not None
if self.has_baseline:
self.diff_entries = sorted(mem_service.get_diff(), key=lambda s: abs(s.size_diff), reverse=True)[:50]
self.expanded_ids.clear()
async def take_baseline(self) -> None:
self.session[MemoryService].store_baseline()
self.has_baseline = True
self.diff_entries = []
self.expanded_ids.clear()
async def clear_baseline(self) -> None:
self.session[MemoryService].clear_baseline()
self.has_baseline = False
self.diff_entries = []
self.expanded_ids.clear()
def _filter_frames(self, stat) -> list[str]:
frames = stat.traceback.format()
return [f for f in frames if "tracemalloc.py" not in f] if frames else ["unknown"]
def _get_leaf_frame(self, stat) -> str:
frames = self._filter_frames(stat)
return frames[-1] if frames else "unknown"
def _get_all_frames(self, stat) -> list[str]:
return self._filter_frames(stat)
async def toggle_expand(self, entry_id: int) -> None:
if entry_id in self.expanded_ids:
self.expanded_ids.discard(entry_id)
else:
self.expanded_ids.add(entry_id)
def _build_diff_row(self, stat, index: int) -> Component:
size_str = self._format_size(abs(stat.size_diff))
sign = "+" if stat.size_diff >= 0 else "-"
leaf = self._get_leaf_frame(stat)
all_frames = self._get_all_frames(stat)
is_expanded = index in self.expanded_ids
has_multiple = len(all_frames) > 1
indicator = "▼" if (is_expanded and has_multiple) else ("▶" if has_multiple else " ")
content_parts = [
Row(
PointerEventListener(
content=Rectangle(
content=Text(indicator, selectable=False),
cursor="pointer" if has_multiple else "default"
),
on_press=(lambda event, i=index: self.toggle_expand(i)) if has_multiple else None,
),
Text(leaf, grow_x=True, selectable=True, overflow="wrap"),
Text(f"{sign}{size_str}", margin_left=1, selectable=False),
margin_bottom=0.1,
)
]
if is_expanded and has_multiple:
for frame in all_frames:
content_parts.append(
Row(
Text(" └ " + frame, grow_x=True, selectable=True, overflow="wrap", margin_bottom=0.05),
)
)
return Column(*content_parts, margin_bottom=0.3)
def build(self) -> Component:
mem_service = self.session[MemoryService]
if not mem_service.is_enabled():
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Speicheranalyse (tracemalloc)", margin=0.5, selectable=False, overflow="wrap"),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
Text(
"tracemalloc ist nicht aktiviert. Aktiviere es in config.toml unter [debug] tracemalloc_enabled=true und starte die Anwendung neu.",
margin=1,
selectable=False
),
Spacer()
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
margin=1,
min_width=40
)
if self.is_loading:
return Rectangle(
content=Column(
Text("Lade...", margin=1, selectable=False),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
margin=1,
min_width=40
)
diff_rows = []
if self.has_baseline:
for index, stat in enumerate(self.diff_entries):
diff_rows.append(self._build_diff_row(stat, index))
else:
diff_rows = [Text("Noch keine Baseline vorhanden. Nutze 'Baseline-Snapshot aufnehmen' um eine Baseline zu erstellen.", margin=0.5, selectable=False)]
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Speicheranalyse (tracemalloc)", margin=0.5, selectable=False, overflow="wrap"),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
Row(
ElmButton(text="Baseline-Snapshot aufnehmen", on_press=self.take_baseline),
ElmButton(text="Baseline-Snapshot löschen", on_press=self.clear_baseline, is_disabled=not self.has_baseline),
ElmButton(text="Aktualisieren", on_press=self.refresh, is_disabled=not self.has_baseline),
spacing=0.5,
margin_bottom=1
),
Column(
*diff_rows,
margin=1,
spacing=0.2
),
Spacer()
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
margin=1,
min_width=60
)
+7
View File
@@ -141,3 +141,10 @@ class ConfigurationService:
@property
def BASE_URL(self) -> str:
return self._config["misc"]["base_url"]
@property
def TRACEMALLOC_ENABLED(self) -> bool:
try:
return self._config["debug"]["tracemalloc_enabled"]
except KeyError:
return False
+30
View File
@@ -0,0 +1,30 @@
import tracemalloc
from typing import Optional
class MemoryService:
_baseline: Optional[tracemalloc.Snapshot] = None
def __init__(self, is_enabled: bool) -> None:
self._is_enabled = is_enabled
def is_enabled(self) -> bool:
return self._is_enabled
def take_snapshot(self) -> tracemalloc.Snapshot:
return tracemalloc.take_snapshot()
def store_baseline(self) -> None:
self._baseline = self.take_snapshot()
def get_baseline(self) -> Optional[tracemalloc.Snapshot]:
return self._baseline
def clear_baseline(self) -> None:
self._baseline = None
def get_diff(self) -> list[tracemalloc.StatisticDiff]:
if self._baseline is None:
return []
current = self.take_snapshot()
return current.compare_to(self._baseline, "traceback")
+1
View File
@@ -6,3 +6,4 @@ from .MailingService import MailingService
from .AccountingService import AccountingService
from .PreloadService import PreloadService
from .ReceiptPrintingService import ReceiptPrintingService
from .MemoryService import MemoryService