console-error-scanner 1.16.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. console_error_scanner/__init__.py +5 -0
  2. console_error_scanner/__main__.py +370 -0
  3. console_error_scanner/app.py +1870 -0
  4. console_error_scanner/app.tcss +91 -0
  5. console_error_scanner/i18n.py +74 -0
  6. console_error_scanner/locale/de.json +417 -0
  7. console_error_scanner/locale/en.json +417 -0
  8. console_error_scanner/models/__init__.py +1 -0
  9. console_error_scanner/models/history.py +220 -0
  10. console_error_scanner/models/robots.py +154 -0
  11. console_error_scanner/models/scan_result.py +265 -0
  12. console_error_scanner/models/settings.py +242 -0
  13. console_error_scanner/models/sitemap.py +347 -0
  14. console_error_scanner/models/whitelist.py +226 -0
  15. console_error_scanner/screens/__init__.py +1 -0
  16. console_error_scanner/screens/diet_advisor.py +128 -0
  17. console_error_scanner/screens/error_detail.py +164 -0
  18. console_error_scanner/screens/history.py +187 -0
  19. console_error_scanner/screens/scan_summary.py +234 -0
  20. console_error_scanner/screens/settings.py +292 -0
  21. console_error_scanner/screens/top_errors.py +304 -0
  22. console_error_scanner/screens/whitelist.py +154 -0
  23. console_error_scanner/services/__init__.py +1 -0
  24. console_error_scanner/services/image_clipboard.py +104 -0
  25. console_error_scanner/services/preview_service.py +291 -0
  26. console_error_scanner/services/rate_limit.py +56 -0
  27. console_error_scanner/services/reporter.py +561 -0
  28. console_error_scanner/services/scanner.py +965 -0
  29. console_error_scanner/services/site_score.py +151 -0
  30. console_error_scanner/widgets/__init__.py +1 -0
  31. console_error_scanner/widgets/bar_chart.py +60 -0
  32. console_error_scanner/widgets/preview_panel.py +356 -0
  33. console_error_scanner/widgets/results_table.py +594 -0
  34. console_error_scanner/widgets/stats_panel.py +387 -0
  35. console_error_scanner/widgets/summary_panel.py +176 -0
  36. console_error_scanner-1.16.3.dist-info/METADATA +509 -0
  37. console_error_scanner-1.16.3.dist-info/RECORD +41 -0
  38. console_error_scanner-1.16.3.dist-info/WHEEL +5 -0
  39. console_error_scanner-1.16.3.dist-info/entry_points.txt +2 -0
  40. console_error_scanner-1.16.3.dist-info/licenses/LICENSE +202 -0
  41. console_error_scanner-1.16.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,292 @@
1
+ """Settings-Dialog fuer den Console Error Scanner.
2
+
3
+ Erbt vom standardisierten ``BaseSettingsScreen`` (textual-widgets): die Basis
4
+ liefert Look, Sprach-Tab und Save/Cancel; diese Klasse ergaenzt nur den
5
+ Scanner-Tab mit den App-spezifischen Optionen.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from textual import on
13
+ from textual.app import ComposeResult
14
+ from textual.containers import Horizontal, VerticalScroll
15
+ from textual.widgets import Checkbox, Input, Label, Select, Static, TabPane
16
+ from textual_slider import Slider
17
+ from textual_widgets import BaseSettingsScreen
18
+
19
+ from ..i18n import t
20
+ from ..models.history import History
21
+ from ..models.settings import CRASH_LOG_NAME, SETTINGS_FILE
22
+
23
+
24
+ class ScannerSettingsScreen(BaseSettingsScreen):
25
+ """Settings: Sprache (von der Basis) + Scanner-Optionen."""
26
+
27
+ DEFAULT_CSS = """
28
+ /* Label-Spalte: Label + (?)-Icon nebeneinander, gemeinsame feste Breite,
29
+ damit alle Eingabefelder bündig untereinander stehen. Breite muss das
30
+ laengste Label ("Groessen-Warnung (MB)" / "Size warning (MB)") plus das
31
+ (?)-Icon fassen, sonst wird das Icon abgeschnitten. */
32
+ ScannerSettingsScreen .label-with-icon {
33
+ width: 26;
34
+ height: 1;
35
+ }
36
+ ScannerSettingsScreen .label-with-icon Label {
37
+ width: auto;
38
+ padding: 0 1 0 1;
39
+ }
40
+ ScannerSettingsScreen .info-icon {
41
+ width: auto;
42
+ height: 1;
43
+ color: cyan;
44
+ text-style: bold;
45
+ padding: 0 1 0 0;
46
+ }
47
+ ScannerSettingsScreen .info-icon:hover {
48
+ color: white;
49
+ background: cyan 30%;
50
+ }
51
+ ScannerSettingsScreen .rate-value {
52
+ padding: 0 1;
53
+ }
54
+ ScannerSettingsScreen .rate-value.off {
55
+ color: $text-disabled;
56
+ }
57
+ ScannerSettingsScreen #set-rate {
58
+ width: 1fr;
59
+ margin: 0 1;
60
+ }
61
+ """
62
+
63
+ def app_tabs(self) -> ComposeResult:
64
+ """Scanner-Tab: Consent, Scroll, Concurrency, Timeout, Whitelist, …"""
65
+ with TabPane(t("settings.tab_scanner"), id="settings-tab-scanner"), VerticalScroll():
66
+ with Horizontal(classes="settings-row"):
67
+ yield from self._label_with_icon(t("settings.consent_label"), t("settings.consent_tip"))
68
+ yield Checkbox(
69
+ t("settings.consent_checkbox"),
70
+ value=bool(self._settings.get("accept_consent", True)),
71
+ id="set-consent",
72
+ )
73
+ with Horizontal(classes="settings-row"):
74
+ yield from self._label_with_icon(t("settings.scroll_label"), t("settings.scroll_tip"))
75
+ yield Checkbox(
76
+ t("settings.scroll_checkbox"),
77
+ value=bool(self._settings.get("trigger_lazy_load", True)),
78
+ id="set-scroll",
79
+ )
80
+ with Horizontal(classes="settings-row"):
81
+ yield from self._label_with_icon(t("settings.robots_label"), t("settings.robots_tip"))
82
+ yield Checkbox(
83
+ t("settings.robots_checkbox"),
84
+ value=bool(self._settings.get("respect_robots", True)),
85
+ id="set-robots",
86
+ )
87
+ with Horizontal(classes="settings-row"):
88
+ yield from self._label_with_icon(t("settings.concurrency_label"), t("settings.concurrency_tip"))
89
+ yield Input(
90
+ value=str(self._settings.get("concurrency", 8)),
91
+ type="integer",
92
+ id="set-concurrency",
93
+ )
94
+ rate_on = bool(self._settings.get("rate_limit_enabled", True))
95
+ rate_value = self._clamp(self._settings.get("rate_per_minute", 60), 60, 10, 240)
96
+ with Horizontal(classes="settings-row"):
97
+ yield from self._label_with_icon(t("settings.rate_label"), t("settings.rate_tip"))
98
+ yield Checkbox(
99
+ t("settings.rate_checkbox"),
100
+ value=rate_on,
101
+ id="set-rate-on",
102
+ )
103
+ yield Static(
104
+ self._rate_label(rate_value),
105
+ id="rate-value",
106
+ classes="rate-value" if rate_on else "rate-value off",
107
+ )
108
+ yield Slider(min=10, max=240, step=10, value=rate_value, id="set-rate", disabled=not rate_on)
109
+ with Horizontal(classes="settings-row"):
110
+ yield from self._label_with_icon(t("settings.timeout_label"), t("settings.timeout_tip"))
111
+ yield Input(
112
+ value=str(self._settings.get("timeout", 60)),
113
+ type="integer",
114
+ id="set-timeout",
115
+ )
116
+ with Horizontal(classes="settings-row"):
117
+ yield from self._label_with_icon(t("settings.size_warn_label"), t("settings.size_warn_tip"))
118
+ yield Input(
119
+ value=str(self._settings.get("size_warn_mb", 10)),
120
+ type="integer",
121
+ id="set-size-warn",
122
+ )
123
+ with Horizontal(classes="settings-row"):
124
+ yield from self._label_with_icon(t("settings.score_weight_label"), t("settings.score_weight_tip"))
125
+ yield Input(
126
+ value=str(self._settings.get("score_error_weight", 60)),
127
+ type="integer",
128
+ id="set-score-weight",
129
+ )
130
+ with Horizontal(classes="settings-row"):
131
+ yield from self._label_with_icon(t("settings.console_level_label"), t("settings.console_level_tip"))
132
+ yield Select(
133
+ [("error", "error"), ("warn", "warn"), ("all", "all")],
134
+ value=str(self._settings.get("console_level", "warn")),
135
+ allow_blank=False,
136
+ id="set-console-level",
137
+ )
138
+ with Horizontal(classes="settings-row"):
139
+ yield from self._label_with_icon(t("settings.preview_label"), t("settings.preview_tip"))
140
+ yield Checkbox(
141
+ t("settings.preview_checkbox"),
142
+ value=bool(self._settings.get("show_preview", False)),
143
+ id="set-preview",
144
+ )
145
+ with Horizontal(classes="settings-row"):
146
+ yield from self._label_with_icon(t("settings.headless_label"), t("settings.headless_tip"))
147
+ yield Checkbox(
148
+ t("settings.headless_checkbox"),
149
+ value=bool(self._settings.get("no_headless", False)),
150
+ id="set-no-headless",
151
+ )
152
+ with Horizontal(classes="settings-row"):
153
+ yield from self._label_with_icon(t("settings.whitelist_label"), t("settings.whitelist_tip"))
154
+ yield Input(
155
+ value=str(self._settings.get("whitelist_path", "")),
156
+ placeholder=t("settings.whitelist_placeholder"),
157
+ id="set-whitelist",
158
+ )
159
+ with Horizontal(classes="settings-row"):
160
+ yield from self._label_with_icon(t("settings.user_agent_label"), t("settings.user_agent_tip"))
161
+ yield Input(
162
+ value=str(self._settings.get("user_agent", "")),
163
+ placeholder=t("settings.user_agent_placeholder"),
164
+ id="set-user-agent",
165
+ )
166
+ with Horizontal(classes="settings-row"):
167
+ yield from self._label_with_icon(t("settings.cookies_label"), t("settings.cookies_tip"))
168
+ yield Input(
169
+ value=str(self._settings.get("cookies", "")),
170
+ placeholder=t("settings.cookies_placeholder"),
171
+ id="set-cookies",
172
+ )
173
+ yield Static(t("settings.app_hint"), classes="settings-hint")
174
+
175
+ with TabPane(t("settings.tab_network"), id="settings-tab-network"), VerticalScroll():
176
+ with Horizontal(classes="settings-row"):
177
+ yield from self._label_with_icon(t("settings.proxy_url_label"), t("settings.proxy_url_tip"))
178
+ yield Input(
179
+ value=str(self._settings.get("proxy_url", "")),
180
+ placeholder="http://proxy.example.com:8080",
181
+ id="set-proxy-url",
182
+ )
183
+ yield Static(t("settings.proxy_hint"), classes="settings-hint")
184
+
185
+ with TabPane(t("settings.tab_export"), id="settings-tab-export"), VerticalScroll():
186
+ with Horizontal(classes="settings-row"):
187
+ yield from self._label_with_icon(t("settings.jira_format_label"), t("settings.jira_format_tip"))
188
+ yield Select(
189
+ [
190
+ (t("settings.jira_format_markdown"), "markdown"),
191
+ (t("settings.jira_format_wiki"), "wiki"),
192
+ ],
193
+ value=str(self._settings.get("jira_format", "markdown")),
194
+ allow_blank=False,
195
+ id="set-jira-format",
196
+ )
197
+ yield Static(t("settings.jira_format_hint"), classes="settings-hint")
198
+
199
+ @staticmethod
200
+ def _clamp(value: object, default: int, lo: int, hi: int) -> int:
201
+ """Begrenzt einen gespeicherten Wert auf den Bereich des Reglers."""
202
+ try:
203
+ return max(lo, min(hi, int(str(value))))
204
+ except (TypeError, ValueError):
205
+ return default
206
+
207
+ @staticmethod
208
+ def _rate_label(per_minute: int) -> str:
209
+ """Uebersetzt die Reglerstellung in Klartext (Zahl + Einordnung)."""
210
+ if per_minute <= 30:
211
+ step = t("settings.rate_step_gentle")
212
+ elif per_minute <= 90:
213
+ step = t("settings.rate_step_careful")
214
+ elif per_minute <= 150:
215
+ step = t("settings.rate_step_brisk")
216
+ else:
217
+ step = t("settings.rate_step_reckless")
218
+ return t("settings.rate_value", count=per_minute, step=step)
219
+
220
+ @on(Slider.Changed, "#set-rate")
221
+ def _on_rate_changed(self, event: Slider.Changed) -> None:
222
+ """Haelt die Beschriftung ueber dem Regler am aktuellen Wert."""
223
+ self.query_one("#rate-value", Static).update(self._rate_label(int(event.slider.value)))
224
+
225
+ @on(Checkbox.Changed, "#set-rate-on")
226
+ def _on_rate_toggled(self, event: Checkbox.Changed) -> None:
227
+ """Sperrt den Regler, solange nicht gedrosselt wird."""
228
+ enabled = bool(event.value)
229
+ self.query_one("#set-rate", Slider).disabled = not enabled
230
+ self.query_one("#rate-value", Static).set_class(not enabled, "off")
231
+
232
+ def _label_with_icon(self, label_text: str, tip: str) -> ComposeResult:
233
+ """Erzeugt Label + (?)-Hover-Tooltip-Icon in der Label-Spalte."""
234
+ with Horizontal(classes="label-with-icon"):
235
+ yield Label(label_text)
236
+ icon = Static(t("settings.info_icon"), classes="info-icon")
237
+ icon.tooltip = tip
238
+ yield icon
239
+
240
+ def collect_app_settings(self, settings: dict[str, object]) -> None:
241
+ """Schreibt die Scanner-Optionen aus den Widgets ins Ergebnis-Dict."""
242
+ settings["accept_consent"] = self.query_one("#set-consent", Checkbox).value
243
+ settings["trigger_lazy_load"] = self.query_one("#set-scroll", Checkbox).value
244
+ settings["respect_robots"] = self.query_one("#set-robots", Checkbox).value
245
+ settings["concurrency"] = self._int("#set-concurrency", 8)
246
+ settings["rate_limit_enabled"] = self.query_one("#set-rate-on", Checkbox).value
247
+ settings["rate_per_minute"] = int(self.query_one("#set-rate", Slider).value)
248
+ settings["timeout"] = self._int("#set-timeout", 60, minimum=5)
249
+ settings["size_warn_mb"] = self._int("#set-size-warn", 10, minimum=0)
250
+ settings["score_error_weight"] = min(100, self._int("#set-score-weight", 60, minimum=0))
251
+ level_select = self.query_one("#set-console-level", Select)
252
+ level_value = level_select.value
253
+ settings["console_level"] = str(level_value) if level_value is not Select.BLANK else "warn"
254
+ settings["show_preview"] = self.query_one("#set-preview", Checkbox).value
255
+ settings["no_headless"] = self.query_one("#set-no-headless", Checkbox).value
256
+ settings["whitelist_path"] = self.query_one("#set-whitelist", Input).value.strip()
257
+ settings["user_agent"] = self.query_one("#set-user-agent", Input).value.strip()
258
+ settings["cookies"] = self.query_one("#set-cookies", Input).value.strip()
259
+ settings["proxy_url"] = self.query_one("#set-proxy-url", Input).value.strip()
260
+ jira_select = self.query_one("#set-jira-format", Select)
261
+ jira_value = jira_select.value
262
+ settings["jira_format"] = str(jira_value) if jira_value is not Select.BLANK else "markdown"
263
+
264
+ def storage_paths(self) -> list[tuple[str, Path]]:
265
+ """Liefert die Persistenz-Pfade fuer den Speicherort-Tab."""
266
+ from ..services.preview_service import CACHE_DIR as PREVIEW_CACHE_DIR
267
+
268
+ paths: list[tuple[str, Path]] = [
269
+ (t("settings.storage.config"), SETTINGS_FILE),
270
+ (t("settings.storage.history"), History.HISTORY_FILE),
271
+ ]
272
+ whitelist_raw = str(self._settings.get("whitelist_path", "")).strip()
273
+ if whitelist_raw:
274
+ paths.append((t("settings.storage.whitelist"), Path(whitelist_raw)))
275
+ paths.append((t("settings.storage.preview_cache"), PREVIEW_CACHE_DIR))
276
+ # Nur auffuehren, wenn es sie gibt - sonst schickt der Tab den Anwender
277
+ # zu einer Datei, die nie angelegt wurde.
278
+ for key, name in (
279
+ ("settings.storage.crash", CRASH_LOG_NAME),
280
+ ("settings.storage.fault", "fault.log"),
281
+ ):
282
+ datei = SETTINGS_FILE.parent / name
283
+ if datei.is_file():
284
+ paths.append((t(key), datei))
285
+ return paths
286
+
287
+ def _int(self, selector: str, default: int, minimum: int = 1) -> int:
288
+ """Liest einen Integer-Wert aus einem Input-Feld (mit Fallback)."""
289
+ try:
290
+ return max(minimum, int(self.query_one(selector, Input).value))
291
+ except (ValueError, TypeError):
292
+ return default
@@ -0,0 +1,304 @@
1
+ """Modal-Screen fuer Top-10-Fehler Chart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from collections.abc import Callable
7
+
8
+ from rich.markup import escape as escape_markup
9
+ from rich.text import Text
10
+ from textual import on
11
+ from textual.app import ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import Horizontal, VerticalScroll
14
+ from textual.screen import ModalScreen
15
+ from textual.widgets import Button, Static
16
+
17
+ from ..i18n import t
18
+ from ..models.scan_result import ErrorType, ScanResult
19
+
20
+ # Maximale Laenge fuer Fehlermeldungen in der Anzeige
21
+ MAX_MSG_LEN = 80
22
+ # Breite des Balkens
23
+ BAR_WIDTH = 40
24
+
25
+
26
+ class TopErrorsScreen(ModalScreen):
27
+ """Modal-Dialog mit Top-10-Fehler als Balkendiagramm."""
28
+
29
+ DEFAULT_CSS = """
30
+ TopErrorsScreen {
31
+ align: center middle;
32
+ }
33
+
34
+ TopErrorsScreen > VerticalScroll {
35
+ width: 90%;
36
+ max-width: 120;
37
+ height: auto;
38
+ max-height: 90%;
39
+ background: $surface;
40
+ border: thick $accent;
41
+ padding: 1 2;
42
+ }
43
+
44
+ TopErrorsScreen #top-title {
45
+ height: 3;
46
+ content-align: center middle;
47
+ text-style: bold;
48
+ background: $accent;
49
+ color: auto;
50
+ margin-bottom: 1;
51
+ }
52
+
53
+ TopErrorsScreen #top-content {
54
+ height: auto;
55
+ }
56
+
57
+ TopErrorsScreen #top-buttons {
58
+ height: 3;
59
+ align: center middle;
60
+ margin-top: 1;
61
+ }
62
+
63
+ TopErrorsScreen #top-buttons Button {
64
+ margin: 0 1;
65
+ }
66
+ """
67
+
68
+ BINDINGS = [
69
+ Binding("escape", "close", "Close"),
70
+ Binding("q", "close", "Close"),
71
+ ]
72
+
73
+ def __init__(self, results: list[ScanResult], **kwargs) -> None:
74
+ super().__init__(**kwargs)
75
+ self._results = results
76
+
77
+ def _link(self, display: str, target: str) -> Text:
78
+ """Baut einen Hover-klickbaren Link-Text (volle URL als Klickziel).
79
+
80
+ Args:
81
+ display:
82
+ Der angezeigte (ggf. gekuerzte) Text.
83
+ target:
84
+ Die vollstaendige URL, die beim Klick geoeffnet wird.
85
+
86
+ Returns:
87
+ Ein ``Text`` mit ``@click``-Meta - oder unverlinkt, falls die App
88
+ keinen ``link_markup``-Helfer bereitstellt.
89
+ """
90
+ link_fn = getattr(self.app, "link_markup", None)
91
+ if callable(link_fn):
92
+ return Text.from_markup(link_fn(escape_markup(display), target))
93
+ return Text(display)
94
+
95
+ def compose(self) -> ComposeResult:
96
+ """Erstellt das Modal-Layout."""
97
+ with VerticalScroll():
98
+ yield Static(t("top_errors.title"), id="top-title")
99
+ yield Static(self._build_chart(), id="top-content")
100
+ with Horizontal(id="top-buttons"):
101
+ yield Button(t("binding.close"), variant="primary", id="top-close")
102
+
103
+ @on(Button.Pressed, "#top-close")
104
+ def _on_close_button(self) -> None:
105
+ self.dismiss()
106
+
107
+ def _build_chart(self) -> Text:
108
+ """Erstellt das Balkendiagramm der Top-10-Fehler.
109
+
110
+ Returns:
111
+ Formatierter Rich Text mit Balkendiagramm.
112
+ """
113
+ text = Text()
114
+
115
+ # Alle Fehler sammeln und nach Typ gruppieren
116
+ console_counter: Counter = Counter()
117
+ warning_counter: Counter = Counter()
118
+ http_404_counter: Counter = Counter()
119
+ http_4xx_counter: Counter = Counter()
120
+ http_5xx_counter: Counter = Counter()
121
+
122
+ for result in self._results:
123
+ for error in result.errors:
124
+ if error.whitelisted:
125
+ continue
126
+ if error.error_type == ErrorType.CONSOLE_ERROR:
127
+ msg = _normalize_message(error.message)
128
+ console_counter[msg] += 1
129
+ elif error.error_type == ErrorType.CONSOLE_WARNING:
130
+ msg = _normalize_message(error.message)
131
+ warning_counter[msg] += 1
132
+ elif error.error_type == ErrorType.HTTP_404:
133
+ http_404_counter[error.source or error.message] += 1
134
+ elif error.error_type == ErrorType.HTTP_4XX:
135
+ http_4xx_counter[error.source or error.message] += 1
136
+ elif error.error_type == ErrorType.HTTP_5XX:
137
+ http_5xx_counter[error.source or error.message] += 1
138
+
139
+ # Gesamtzaehler
140
+ total_errors = (
141
+ sum(console_counter.values())
142
+ + sum(warning_counter.values())
143
+ + sum(http_404_counter.values())
144
+ + sum(http_4xx_counter.values())
145
+ + sum(http_5xx_counter.values())
146
+ )
147
+
148
+ if total_errors == 0:
149
+ text.append(t("top_errors.no_errors"), style="green bold")
150
+ return text
151
+
152
+ text.append(t("top_errors.total", count=total_errors), style="bold")
153
+ pages_with_errors = sum(1 for r in self._results if r.has_issues)
154
+ text.append(f"{t('top_errors.pages', count=pages_with_errors)}\n", style="bold")
155
+ text.append("\n")
156
+
157
+ # === Console Errors Top 10 ===
158
+ if console_counter:
159
+ _append_section(text, "Console Errors", console_counter, "red")
160
+
161
+ # === Console Warnings Top 10 ===
162
+ if warning_counter:
163
+ _append_section(text, "Console Warnings", warning_counter, "yellow")
164
+
165
+ # === HTTP 404 Top 10 === (Eintraege sind URLs -> Hover-Links)
166
+ if http_404_counter:
167
+ _append_section(text, "HTTP 404", http_404_counter, "yellow", link_fn=self._link)
168
+
169
+ # === HTTP 4xx Top 10 ===
170
+ if http_4xx_counter:
171
+ _append_section(text, "HTTP 4xx", http_4xx_counter, "yellow", link_fn=self._link)
172
+
173
+ # === HTTP 5xx Top 10 ===
174
+ if http_5xx_counter:
175
+ _append_section(text, "HTTP 5xx", http_5xx_counter, "red", link_fn=self._link)
176
+
177
+ # === Seiten mit den meisten Fehlern ===
178
+ text.append("\u2500" * 60, style="dim")
179
+ text.append("\n\n")
180
+ text.append(f"{t('top_errors.most_errors')}\n", style="bold cyan underline")
181
+ text.append("\n")
182
+ page_errors = [(r.url, r.total_error_count) for r in self._results if r.has_issues]
183
+ page_errors.sort(key=lambda x: x[1], reverse=True)
184
+
185
+ if page_errors:
186
+ max_page_count = page_errors[0][1]
187
+ for rank, (url, count) in enumerate(page_errors[:10], 1):
188
+ label = self._link(_truncate(url, MAX_MSG_LEN), url)
189
+ _append_bar_entry(text, rank, label, count, max_page_count, "cyan")
190
+
191
+ return text
192
+
193
+ def action_close(self) -> None:
194
+ """Schliesst den Dialog."""
195
+ self.dismiss()
196
+
197
+
198
+ def _append_section(
199
+ text: Text,
200
+ title: str,
201
+ counter: Counter,
202
+ color: str,
203
+ link_fn: Callable[[str, str], Text] | None = None,
204
+ ) -> None:
205
+ """Fuegt eine Chart-Sektion mit Balkendiagramm hinzu.
206
+
207
+ Args:
208
+ text: Rich Text zum Anhaengen.
209
+ title: Titel der Sektion.
210
+ counter: Counter mit Fehlermeldung -> Anzahl.
211
+ color: Farbe der Balken.
212
+ link_fn:
213
+ Optionaler Helfer (display, target) -> klickbarer Text. Gesetzt,
214
+ wenn die Counter-Keys URLs sind (HTTP-Sektionen) - dann wird das
215
+ Label ein Hover-Link mit der vollen URL als Klickziel.
216
+ """
217
+ text.append(f"{title}\n", style=f"bold {color} underline")
218
+ text.append("\n")
219
+
220
+ max_count = counter.most_common(1)[0][1] if counter else 1
221
+ for rank, (msg, count) in enumerate(counter.most_common(10), 1):
222
+ display = _truncate(msg, MAX_MSG_LEN)
223
+ label: str | Text = link_fn(display, msg) if link_fn else display
224
+ _append_bar_entry(text, rank, label, count, max_count, color)
225
+
226
+ text.append("\n")
227
+
228
+
229
+ def _normalize_message(msg: str) -> str:
230
+ """Normalisiert eine Fehlermeldung fuer Gruppierung.
231
+
232
+ Entfernt variable Teile wie URLs mit Parametern, Timestamps etc.
233
+ Behaelt den Kerninhalt fuer die Gruppierung.
234
+
235
+ Args:
236
+ msg: Originale Fehlermeldung.
237
+
238
+ Returns:
239
+ Normalisierte Version.
240
+ """
241
+ if not msg:
242
+ return t("top_errors.empty")
243
+
244
+ # Erste Zeile nehmen
245
+ first_line = msg.split("\n")[0].strip()
246
+
247
+ # Sehr lange Meldungen kuerzen
248
+ if len(first_line) > 120:
249
+ first_line = f"{first_line[:117]}..."
250
+
251
+ return first_line
252
+
253
+
254
+ def _truncate(text: str, max_len: int) -> str:
255
+ """Kuerzt einen Text auf maximale Laenge.
256
+
257
+ Args:
258
+ text: Der zu kuerzende Text.
259
+ max_len: Maximale Laenge.
260
+
261
+ Returns:
262
+ Gekuerzter Text.
263
+ """
264
+ if len(text) <= max_len:
265
+ return text
266
+ return f"{text[: max_len - 3]}..."
267
+
268
+
269
+ def _append_bar_entry(
270
+ text: Text,
271
+ rank: int,
272
+ label: str | Text,
273
+ count: int,
274
+ max_count: int,
275
+ color: str,
276
+ ) -> None:
277
+ """Fuegt einen Chart-Eintrag hinzu (Label ueber Balken).
278
+
279
+ Layout:
280
+ 1. Fehlermeldung hier...
281
+ ████████████████████████████ 15x
282
+
283
+ Args:
284
+ text: Rich Text zum Anhaengen.
285
+ rank: Rang-Nummer.
286
+ label: Beschreibungstext.
287
+ count: Anzahl.
288
+ max_count: Maximaler Wert (fuer Balkenbreite).
289
+ color: Farbe des Balkens.
290
+ """
291
+ # Zeile 1: Rang + Label (Label kann ein klickbarer Text sein)
292
+ text.append(f" {rank:2d}. ", style="bold")
293
+ if isinstance(label, Text):
294
+ text.append_text(label)
295
+ text.append("\n")
296
+ else:
297
+ text.append(f"{label}\n", style="")
298
+
299
+ # Zeile 2: Eingerueckter Balken + Anzahl
300
+ bar_len = max(1, int(BAR_WIDTH * count / max_count)) if max_count > 0 else 1
301
+ bar = "\u2588" * bar_len
302
+ padding = " " * 6 # Einrueckung passend zum Label
303
+ text.append(f"{padding}{bar}", style=f"bold {color}")
304
+ text.append(f" {count}x\n", style="bold")