diff --git a/.gitignore b/.gitignore index d17b523..3a26f00 100644 --- a/.gitignore +++ b/.gitignore @@ -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 +*~ \ No newline at end of file diff --git a/config.example.toml b/config.example.toml index 5a28413..f978d3e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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="" diff --git a/docker-compose.yml b/docker-compose.yml index 7607f49..eb6153d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/elm/__init__.py b/src/elm/__init__.py index f1b8407..9678f14 100644 --- a/src/elm/__init__.py +++ b/src/elm/__init__.py @@ -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"), diff --git a/src/elm/components/UserNavigation.py b/src/elm/components/UserNavigation.py index 2c59e07..19008ae 100644 --- a/src/elm/components/UserNavigation.py +++ b/src/elm/components/UserNavigation.py @@ -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) diff --git a/src/elm/pages/MemoryAnalyzerPage.py b/src/elm/pages/MemoryAnalyzerPage.py new file mode 100644 index 0000000..d6fd929 --- /dev/null +++ b/src/elm/pages/MemoryAnalyzerPage.py @@ -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 + ) \ No newline at end of file diff --git a/src/elm/services/ConfigurationService.py b/src/elm/services/ConfigurationService.py index cf01bfd..7745a51 100644 --- a/src/elm/services/ConfigurationService.py +++ b/src/elm/services/ConfigurationService.py @@ -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 diff --git a/src/elm/services/MemoryService.py b/src/elm/services/MemoryService.py new file mode 100644 index 0000000..3ebefd0 --- /dev/null +++ b/src/elm/services/MemoryService.py @@ -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") \ No newline at end of file diff --git a/src/elm/services/__init__.py b/src/elm/services/__init__.py index bec1abc..78ce8fd 100644 --- a/src/elm/services/__init__.py +++ b/src/elm/services/__init__.py @@ -6,3 +6,4 @@ from .MailingService import MailingService from .AccountingService import AccountingService from .PreloadService import PreloadService from .ReceiptPrintingService import ReceiptPrintingService +from .MemoryService import MemoryService