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.
- console_error_scanner/__init__.py +5 -0
- console_error_scanner/__main__.py +370 -0
- console_error_scanner/app.py +1870 -0
- console_error_scanner/app.tcss +91 -0
- console_error_scanner/i18n.py +74 -0
- console_error_scanner/locale/de.json +417 -0
- console_error_scanner/locale/en.json +417 -0
- console_error_scanner/models/__init__.py +1 -0
- console_error_scanner/models/history.py +220 -0
- console_error_scanner/models/robots.py +154 -0
- console_error_scanner/models/scan_result.py +265 -0
- console_error_scanner/models/settings.py +242 -0
- console_error_scanner/models/sitemap.py +347 -0
- console_error_scanner/models/whitelist.py +226 -0
- console_error_scanner/screens/__init__.py +1 -0
- console_error_scanner/screens/diet_advisor.py +128 -0
- console_error_scanner/screens/error_detail.py +164 -0
- console_error_scanner/screens/history.py +187 -0
- console_error_scanner/screens/scan_summary.py +234 -0
- console_error_scanner/screens/settings.py +292 -0
- console_error_scanner/screens/top_errors.py +304 -0
- console_error_scanner/screens/whitelist.py +154 -0
- console_error_scanner/services/__init__.py +1 -0
- console_error_scanner/services/image_clipboard.py +104 -0
- console_error_scanner/services/preview_service.py +291 -0
- console_error_scanner/services/rate_limit.py +56 -0
- console_error_scanner/services/reporter.py +561 -0
- console_error_scanner/services/scanner.py +965 -0
- console_error_scanner/services/site_score.py +151 -0
- console_error_scanner/widgets/__init__.py +1 -0
- console_error_scanner/widgets/bar_chart.py +60 -0
- console_error_scanner/widgets/preview_panel.py +356 -0
- console_error_scanner/widgets/results_table.py +594 -0
- console_error_scanner/widgets/stats_panel.py +387 -0
- console_error_scanner/widgets/summary_panel.py +176 -0
- console_error_scanner-1.16.3.dist-info/METADATA +509 -0
- console_error_scanner-1.16.3.dist-info/RECORD +41 -0
- console_error_scanner-1.16.3.dist-info/WHEEL +5 -0
- console_error_scanner-1.16.3.dist-info/entry_points.txt +2 -0
- console_error_scanner-1.16.3.dist-info/licenses/LICENSE +202 -0
- 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")
|