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,164 @@
|
|
|
1
|
+
"""Modal-Screen fuer Fehlerdetails."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import unquote
|
|
6
|
+
|
|
7
|
+
from rich.markup import escape as escape_markup
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import Button, Static
|
|
14
|
+
|
|
15
|
+
from ..i18n import t
|
|
16
|
+
from ..models.scan_result import ErrorType, ScanResult
|
|
17
|
+
|
|
18
|
+
_TYPE_LABELS: dict[ErrorType, str] = {
|
|
19
|
+
ErrorType.CONSOLE_ERROR: "CONSOLE",
|
|
20
|
+
ErrorType.CONSOLE_WARNING: "WARNING",
|
|
21
|
+
ErrorType.HTTP_404: "HTTP 404",
|
|
22
|
+
ErrorType.HTTP_4XX: "HTTP 4xx",
|
|
23
|
+
ErrorType.HTTP_5XX: "HTTP 5xx",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_TYPE_STYLES: dict[ErrorType, str] = {
|
|
27
|
+
ErrorType.CONSOLE_ERROR: "bold red",
|
|
28
|
+
ErrorType.CONSOLE_WARNING: "bold yellow",
|
|
29
|
+
ErrorType.HTTP_404: "bold yellow",
|
|
30
|
+
ErrorType.HTTP_4XX: "bold yellow",
|
|
31
|
+
ErrorType.HTTP_5XX: "bold red",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ErrorDetailScreen(ModalScreen[None]):
|
|
36
|
+
"""Modal-Dialog mit ausfuehrlichen Fehlerdetails einer URL."""
|
|
37
|
+
|
|
38
|
+
DEFAULT_CSS = """
|
|
39
|
+
ErrorDetailScreen {
|
|
40
|
+
align: center middle;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ErrorDetailScreen > Vertical {
|
|
44
|
+
width: 80%;
|
|
45
|
+
max-width: 110;
|
|
46
|
+
height: auto;
|
|
47
|
+
max-height: 90%;
|
|
48
|
+
background: $surface;
|
|
49
|
+
border: thick $accent;
|
|
50
|
+
padding: 1 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ErrorDetailScreen #detail-title {
|
|
54
|
+
height: auto;
|
|
55
|
+
max-height: 5;
|
|
56
|
+
content-align: center middle;
|
|
57
|
+
text-style: bold;
|
|
58
|
+
background: $accent;
|
|
59
|
+
color: auto;
|
|
60
|
+
margin-bottom: 1;
|
|
61
|
+
padding: 1 2;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
ErrorDetailScreen #detail-scroll {
|
|
65
|
+
height: 1fr;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ErrorDetailScreen #detail-content {
|
|
69
|
+
height: auto;
|
|
70
|
+
padding: 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ErrorDetailScreen #detail-buttons {
|
|
74
|
+
height: 3;
|
|
75
|
+
align: center middle;
|
|
76
|
+
margin-top: 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
ErrorDetailScreen #detail-buttons Button {
|
|
80
|
+
margin: 0 1;
|
|
81
|
+
}
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
BINDINGS = [
|
|
85
|
+
Binding("escape", "close", "Close"),
|
|
86
|
+
Binding("q,Q", "close", "Close"),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def __init__(self, result: ScanResult, **kwargs) -> None:
|
|
90
|
+
super().__init__(**kwargs)
|
|
91
|
+
self._result = result
|
|
92
|
+
|
|
93
|
+
def compose(self) -> ComposeResult:
|
|
94
|
+
with Vertical():
|
|
95
|
+
yield Static(t("error_detail_screen.title", url=self._result.url), id="detail-title")
|
|
96
|
+
with VerticalScroll(id="detail-scroll"):
|
|
97
|
+
yield Static(self._build_markup(), id="detail-content", markup=True)
|
|
98
|
+
with Horizontal(id="detail-buttons"):
|
|
99
|
+
yield Button(t("binding.close"), variant="primary", id="detail-close")
|
|
100
|
+
|
|
101
|
+
def _link(self, text: str, target: str) -> str:
|
|
102
|
+
"""Erzeugt ein Hover-klickbares Markup-Snippet (ueber ClickableLinksMixin)."""
|
|
103
|
+
link_markup = getattr(self.app, "link_markup", None)
|
|
104
|
+
if callable(link_markup):
|
|
105
|
+
return link_markup(escape_markup(text), target)
|
|
106
|
+
return escape_markup(text)
|
|
107
|
+
|
|
108
|
+
def _build_markup(self) -> str:
|
|
109
|
+
"""Erstellt den Detail-Inhalt als Rich-Markup-String mit Hover-Links."""
|
|
110
|
+
result = self._result
|
|
111
|
+
parts: list[str] = []
|
|
112
|
+
|
|
113
|
+
parts.append(f"[bold]URL:[/] {self._link(result.url, result.url)}")
|
|
114
|
+
parts.append(escape_markup(t("error_detail_screen.http_status", code=result.http_status_code)))
|
|
115
|
+
parts.append(escape_markup(t("error_detail_screen.load_time", time=result.load_time_ms)))
|
|
116
|
+
parts.append(escape_markup(t("error_detail_screen.retries", count=result.retry_count)))
|
|
117
|
+
ignored_info = f", {result.ignored_count} ignored" if result.ignored_count > 0 else ""
|
|
118
|
+
parts.append(
|
|
119
|
+
escape_markup(t("error_detail_screen.total_errors", total=result.total_error_count, ignored=ignored_info))
|
|
120
|
+
)
|
|
121
|
+
parts.append("")
|
|
122
|
+
|
|
123
|
+
active_errors = [e for e in result.errors if not e.whitelisted]
|
|
124
|
+
ignored_errors = [e for e in result.errors if e.whitelisted]
|
|
125
|
+
|
|
126
|
+
if not active_errors and not ignored_errors:
|
|
127
|
+
parts.append(f"[green]{escape_markup(t('error_detail_screen.no_errors'))}[/]")
|
|
128
|
+
return "\n".join(parts)
|
|
129
|
+
|
|
130
|
+
for error in active_errors:
|
|
131
|
+
style = _TYPE_STYLES.get(error.error_type, "")
|
|
132
|
+
label = _TYPE_LABELS.get(error.error_type, "?")
|
|
133
|
+
tag = f"[{style}]\\[{label}][/]" if style else f"\\[{label}]"
|
|
134
|
+
# URLs in der Meldung sind oft percent-encoded - fuer die Anzeige
|
|
135
|
+
# dekodieren (%2F -> /, %3A -> :), spart Platz und ist lesbarer.
|
|
136
|
+
parts.append(f"{tag} {escape_markup(unquote(error.message))}")
|
|
137
|
+
if error.source:
|
|
138
|
+
source = error.source
|
|
139
|
+
if error.line_number:
|
|
140
|
+
source += f":{error.line_number}"
|
|
141
|
+
parts.append(
|
|
142
|
+
f"[dim]{escape_markup(t('error_detail_screen.source', source=''))}[/]"
|
|
143
|
+
f"{self._link(unquote(source), error.source)}"
|
|
144
|
+
)
|
|
145
|
+
parts.append("")
|
|
146
|
+
|
|
147
|
+
if ignored_errors:
|
|
148
|
+
parts.append(
|
|
149
|
+
f"[dim underline]{escape_markup(t('error_detail_screen.whitelist_hits', count=len(ignored_errors)))}[/]"
|
|
150
|
+
)
|
|
151
|
+
parts.append("")
|
|
152
|
+
for error in ignored_errors:
|
|
153
|
+
label = _TYPE_LABELS.get(error.error_type, "?")
|
|
154
|
+
parts.append(f"[dim]\\[{label}] {escape_markup(unquote(error.message))}[/]")
|
|
155
|
+
parts.append("")
|
|
156
|
+
|
|
157
|
+
return "\n".join(parts)
|
|
158
|
+
|
|
159
|
+
@on(Button.Pressed, "#detail-close")
|
|
160
|
+
def _on_close_button(self) -> None:
|
|
161
|
+
self.dismiss(None)
|
|
162
|
+
|
|
163
|
+
def action_close(self) -> None:
|
|
164
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""History-Screen fuer Console Error Scanner.
|
|
2
|
+
|
|
3
|
+
Zeigt eine Liste vergangener Scans und ermoeglicht die Wiederholung
|
|
4
|
+
eines ausgewaehlten Scans.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from textual import on
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import Button, DataTable, Static
|
|
15
|
+
|
|
16
|
+
from ..i18n import t
|
|
17
|
+
from ..models.history import History, HistoryEntry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HistoryScreen(ModalScreen[HistoryEntry | None]):
|
|
21
|
+
"""Modal-Dialog zur Anzeige und Auswahl vergangener Scans.
|
|
22
|
+
|
|
23
|
+
Gibt den ausgewaehlten HistoryEntry per dismiss() zurueck
|
|
24
|
+
oder None wenn der Dialog ohne Auswahl geschlossen wird.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
DEFAULT_CSS = """
|
|
28
|
+
HistoryScreen {
|
|
29
|
+
align: center middle;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
HistoryScreen > Vertical {
|
|
33
|
+
width: 110;
|
|
34
|
+
height: 35;
|
|
35
|
+
background: $surface;
|
|
36
|
+
border: thick $accent;
|
|
37
|
+
padding: 1 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
HistoryScreen #history-title {
|
|
41
|
+
height: 3;
|
|
42
|
+
content-align: center middle;
|
|
43
|
+
text-style: bold;
|
|
44
|
+
background: $accent;
|
|
45
|
+
color: $text;
|
|
46
|
+
margin-bottom: 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
HistoryScreen #history-empty {
|
|
50
|
+
height: auto;
|
|
51
|
+
padding: 2 4;
|
|
52
|
+
content-align: center middle;
|
|
53
|
+
color: $text-muted;
|
|
54
|
+
text-style: italic;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
HistoryScreen #history-table {
|
|
58
|
+
height: 1fr;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
HistoryScreen #history-buttons {
|
|
62
|
+
height: 3;
|
|
63
|
+
align: center middle;
|
|
64
|
+
margin-top: 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
HistoryScreen #history-buttons Button {
|
|
68
|
+
margin: 0 1;
|
|
69
|
+
}
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
BINDINGS = [
|
|
73
|
+
Binding("escape", "close", "Close"),
|
|
74
|
+
Binding("q", "close", "Close"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def __init__(self, **kwargs) -> None:
|
|
78
|
+
super().__init__(**kwargs)
|
|
79
|
+
self._entries: list[HistoryEntry] = []
|
|
80
|
+
|
|
81
|
+
def compose(self) -> ComposeResult:
|
|
82
|
+
"""Erstellt das Modal-Layout."""
|
|
83
|
+
self._entries = History.load()
|
|
84
|
+
|
|
85
|
+
with Vertical():
|
|
86
|
+
yield Static(t("history.title"), id="history-title")
|
|
87
|
+
|
|
88
|
+
if not self._entries:
|
|
89
|
+
yield Static(
|
|
90
|
+
t("history.empty"),
|
|
91
|
+
id="history-empty",
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
table = DataTable(id="history-table", cursor_type="row")
|
|
95
|
+
table.add_columns(
|
|
96
|
+
t("history.col_number"),
|
|
97
|
+
t("history.col_date"),
|
|
98
|
+
t("history.col_url"),
|
|
99
|
+
t("history.col_params"),
|
|
100
|
+
)
|
|
101
|
+
for idx, entry in enumerate(self._entries, start=1):
|
|
102
|
+
# Datum kuerzen
|
|
103
|
+
date_str = entry.timestamp[:16].replace("T", " ") if entry.timestamp else "?"
|
|
104
|
+
|
|
105
|
+
# Hostname extrahieren
|
|
106
|
+
from urllib.parse import urlparse
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
host = urlparse(entry.sitemap_url).hostname or entry.sitemap_url
|
|
110
|
+
except Exception:
|
|
111
|
+
host = entry.sitemap_url
|
|
112
|
+
|
|
113
|
+
# Parameter kompakt zusammenbauen
|
|
114
|
+
params = []
|
|
115
|
+
if entry.cookies:
|
|
116
|
+
cookie_names = ", ".join(c.get("name", "?") for c in entry.cookies)
|
|
117
|
+
params.append(f"--cookie {cookie_names}")
|
|
118
|
+
if entry.whitelist_path:
|
|
119
|
+
params.append(f"--whitelist {entry.whitelist_path}")
|
|
120
|
+
if entry.url_filter:
|
|
121
|
+
params.append(f"--filter {entry.url_filter}")
|
|
122
|
+
if entry.console_level != "warn":
|
|
123
|
+
params.append(f"--level {entry.console_level}")
|
|
124
|
+
if entry.concurrency != 8:
|
|
125
|
+
params.append(f"-c {entry.concurrency}")
|
|
126
|
+
if entry.timeout != 60:
|
|
127
|
+
params.append(f"-t {entry.timeout}")
|
|
128
|
+
if entry.user_agent:
|
|
129
|
+
params.append("--user-agent ...")
|
|
130
|
+
if not entry.accept_consent:
|
|
131
|
+
params.append("--no-consent")
|
|
132
|
+
param_str = " ".join(params) if params else "-"
|
|
133
|
+
|
|
134
|
+
table.add_row(str(idx), date_str, host, param_str, key=str(idx))
|
|
135
|
+
|
|
136
|
+
yield table
|
|
137
|
+
|
|
138
|
+
with Horizontal(id="history-buttons"):
|
|
139
|
+
yield Button(t("history.btn_select"), variant="primary", id="history-select")
|
|
140
|
+
yield Button(t("history.btn_close"), variant="default", id="history-close")
|
|
141
|
+
|
|
142
|
+
def on_mount(self) -> None:
|
|
143
|
+
"""Fokussiert die Tabelle nach dem Oeffnen."""
|
|
144
|
+
if self._entries:
|
|
145
|
+
try:
|
|
146
|
+
table = self.query_one("#history-table", DataTable)
|
|
147
|
+
table.focus()
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
152
|
+
"""Verarbeitet die Auswahl einer Zeile (Enter/Doppelklick).
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
event: Das RowSelected-Event mit dem Key der Zeile.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
idx = int(str(event.row_key.value)) - 1
|
|
159
|
+
if 0 <= idx < len(self._entries):
|
|
160
|
+
self.dismiss(self._entries[idx])
|
|
161
|
+
except (ValueError, IndexError):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
@on(Button.Pressed, "#history-select")
|
|
165
|
+
def _on_select_button(self) -> None:
|
|
166
|
+
"""Übernimmt die markierte Zeile."""
|
|
167
|
+
if not self._entries:
|
|
168
|
+
self.dismiss(None)
|
|
169
|
+
return
|
|
170
|
+
try:
|
|
171
|
+
table = self.query_one("#history-table", DataTable)
|
|
172
|
+
row = table.cursor_row
|
|
173
|
+
if 0 <= row < len(self._entries):
|
|
174
|
+
self.dismiss(self._entries[row])
|
|
175
|
+
return
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
self.dismiss(None)
|
|
179
|
+
|
|
180
|
+
@on(Button.Pressed, "#history-close")
|
|
181
|
+
def _on_close_button(self) -> None:
|
|
182
|
+
"""Schließt den Dialog ohne Auswahl."""
|
|
183
|
+
self.dismiss(None)
|
|
184
|
+
|
|
185
|
+
def action_close(self) -> None:
|
|
186
|
+
"""Schließt den Dialog ohne Auswahl (ESC/q)."""
|
|
187
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Modal-Screen "Scan-Zusammenfassung": Site-Score + Findings + Big Fische.
|
|
2
|
+
|
|
3
|
+
Wird am Ende eines Scans angezeigt: ein Gesamtscore (0-100 %), eine kurze
|
|
4
|
+
Findings-Uebersicht und die groessten Seiten/Ressourcen als Balken-Chart.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
|
|
11
|
+
from rich.markup import escape as escape_markup
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from textual import on
|
|
14
|
+
from textual.app import ComposeResult
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
17
|
+
from textual.events import MouseMove
|
|
18
|
+
from textual.screen import ModalScreen
|
|
19
|
+
from textual.widgets import Button, Static
|
|
20
|
+
|
|
21
|
+
from ..i18n import t
|
|
22
|
+
from ..models.scan_result import format_page_size
|
|
23
|
+
from ..services.site_score import SiteScore
|
|
24
|
+
from ..widgets.bar_chart import render_bars
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _score_style(score: int) -> str:
|
|
28
|
+
"""Farbstil zum Score (gruen/gelb/rot)."""
|
|
29
|
+
if score >= 75:
|
|
30
|
+
return "bold green"
|
|
31
|
+
if score >= 45:
|
|
32
|
+
return "bold yellow"
|
|
33
|
+
return "bold red"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _short_url(url: str) -> str:
|
|
37
|
+
"""Kuerzt eine URL auf Pfad (ohne Host), max. 52 Zeichen."""
|
|
38
|
+
parsed = urlparse(url)
|
|
39
|
+
path = unquote(parsed.path) or "/"
|
|
40
|
+
return path if len(path) <= 52 else path[:51] + "…"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _SummaryContent(Static):
|
|
44
|
+
"""Inhalts-Static der Zusammenfassung mit Hover-Tooltips pro Zeile.
|
|
45
|
+
|
|
46
|
+
Textual kennt keine Tooltips pro Text-Span - ``tooltip`` gilt immer fuer das
|
|
47
|
+
ganze Widget. Damit die abgekuerzten ("...") Links trotzdem die volle URL
|
|
48
|
+
zeigen, wird die Zeile unter dem Cursor ueber ``on_mouse_move`` ermittelt und
|
|
49
|
+
der Widget-Tooltip auf die volle URL dieser Zeile gesetzt (oder geleert).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
53
|
+
super().__init__(*args, **kwargs) # type: ignore[arg-type]
|
|
54
|
+
# Zeilenindex (0-basiert im gerenderten Text) -> volle URL.
|
|
55
|
+
self.line_urls: dict[int, str] = {}
|
|
56
|
+
|
|
57
|
+
def on_mouse_move(self, event: MouseMove) -> None:
|
|
58
|
+
# event.offset.y ist relativ zum Widget inkl. Padding -> Padding-Top
|
|
59
|
+
# abziehen, um auf den Textzeilen-Index zu kommen.
|
|
60
|
+
pad_top = self.styles.padding.top if self.styles.padding else 0
|
|
61
|
+
url = self.line_urls.get(event.offset.y - pad_top)
|
|
62
|
+
self.tooltip = url or None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ScanSummaryScreen(ModalScreen[None]):
|
|
66
|
+
"""Zeigt Site-Score, Findings und die Big Fische nach einem Scan."""
|
|
67
|
+
|
|
68
|
+
DEFAULT_CSS = """
|
|
69
|
+
ScanSummaryScreen {
|
|
70
|
+
align: center middle;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ScanSummaryScreen > Vertical {
|
|
74
|
+
width: 80%;
|
|
75
|
+
max-width: 110;
|
|
76
|
+
height: auto;
|
|
77
|
+
max-height: 90%;
|
|
78
|
+
background: $surface;
|
|
79
|
+
border: thick $accent;
|
|
80
|
+
padding: 1 2;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ScanSummaryScreen #summary-title {
|
|
84
|
+
height: auto;
|
|
85
|
+
max-height: 5;
|
|
86
|
+
content-align: center middle;
|
|
87
|
+
text-style: bold;
|
|
88
|
+
background: $accent;
|
|
89
|
+
color: auto;
|
|
90
|
+
margin-bottom: 1;
|
|
91
|
+
padding: 1 2;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ScanSummaryScreen #summary-scroll {
|
|
95
|
+
/* Auf Inhaltshoehe schrumpfen (kein 1fr - das fuellt sonst den ganzen
|
|
96
|
+
verfuegbaren Platz und draengt den Button mit grosser Luecke nach
|
|
97
|
+
unten). max-height: 90% kappt bei langem Inhalt -> der Bereich
|
|
98
|
+
scrollt; bei kurzem Inhalt sitzt der (unten angedockte) Button direkt
|
|
99
|
+
darunter, ohne Luecke. */
|
|
100
|
+
height: auto;
|
|
101
|
+
max-height: 90%;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ScanSummaryScreen #summary-content {
|
|
105
|
+
height: auto;
|
|
106
|
+
padding: 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
ScanSummaryScreen #summary-buttons {
|
|
110
|
+
/* Unten angedockt: bleibt bei langem (scrollendem) Inhalt immer sichtbar
|
|
111
|
+
und sitzt bei kurzem Inhalt direkt unter dem Scroll-Bereich. */
|
|
112
|
+
dock: bottom;
|
|
113
|
+
height: 3;
|
|
114
|
+
align: center middle;
|
|
115
|
+
margin-top: 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ScanSummaryScreen #summary-buttons Button {
|
|
119
|
+
margin: 0 1;
|
|
120
|
+
}
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
BINDINGS = [
|
|
124
|
+
Binding("escape", "close", "Close"),
|
|
125
|
+
Binding("q,Q", "close", "Close"),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
def __init__(self, score: SiteScore, **kwargs: object) -> None:
|
|
129
|
+
super().__init__(**kwargs) # type: ignore[arg-type]
|
|
130
|
+
self._score = score
|
|
131
|
+
# Zeilenindex -> volle URL, wird in _build_content befuellt.
|
|
132
|
+
self._line_urls: dict[int, str] = {}
|
|
133
|
+
|
|
134
|
+
def compose(self) -> ComposeResult:
|
|
135
|
+
with Vertical():
|
|
136
|
+
yield Static(t("summary.title"), id="summary-title")
|
|
137
|
+
with VerticalScroll(id="summary-scroll"):
|
|
138
|
+
content = _SummaryContent(self._build_content(), id="summary-content")
|
|
139
|
+
content.line_urls = self._line_urls
|
|
140
|
+
yield content
|
|
141
|
+
with Horizontal(id="summary-buttons"):
|
|
142
|
+
yield Button(t("binding.close"), variant="primary", id="summary-close")
|
|
143
|
+
|
|
144
|
+
def _link_path(self, display: str, target: str) -> Text:
|
|
145
|
+
"""Baut einen Hover-klickbaren Pfad-Text.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
display:
|
|
149
|
+
Der angezeigte (ggf. gekuerzte) Pfad.
|
|
150
|
+
target:
|
|
151
|
+
Die vollstaendige URL, die beim Klick geoeffnet wird.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Ein ``Text`` mit ``@click``-Meta - oder unverlinkt (dim), falls die
|
|
155
|
+
App keinen ``link_markup``-Helfer bereitstellt.
|
|
156
|
+
"""
|
|
157
|
+
link_fn = getattr(self.app, "link_markup", None)
|
|
158
|
+
if callable(link_fn):
|
|
159
|
+
return Text.from_markup(link_fn(escape_markup(display), target))
|
|
160
|
+
return Text(display, style="dim")
|
|
161
|
+
|
|
162
|
+
def _build_content(self) -> Text:
|
|
163
|
+
s = self._score
|
|
164
|
+
text = Text()
|
|
165
|
+
|
|
166
|
+
# Grosse Score-Zeile.
|
|
167
|
+
text.append(t("summary.score_label") + " ", style="bold")
|
|
168
|
+
text.append(f"{s.score} %", style=_score_style(s.score))
|
|
169
|
+
text.append(f" ({s.grade})", style=_score_style(s.score))
|
|
170
|
+
text.append("\n")
|
|
171
|
+
text.append(
|
|
172
|
+
t(
|
|
173
|
+
"summary.score_breakdown",
|
|
174
|
+
err=s.error_score,
|
|
175
|
+
err_w=s.error_weight,
|
|
176
|
+
size=s.size_score,
|
|
177
|
+
size_w=100 - s.error_weight,
|
|
178
|
+
),
|
|
179
|
+
style="dim",
|
|
180
|
+
)
|
|
181
|
+
text.append("\n\n")
|
|
182
|
+
|
|
183
|
+
# Findings.
|
|
184
|
+
text.append(t("summary.findings"), style="bold underline")
|
|
185
|
+
text.append("\n")
|
|
186
|
+
text.append(t("summary.pages", total=s.total_pages, clean=s.clean_pages, err=s.pages_with_errors))
|
|
187
|
+
text.append("\n")
|
|
188
|
+
text.append(t("summary.errors", errors=s.total_errors, warnings=s.total_warnings))
|
|
189
|
+
text.append("\n")
|
|
190
|
+
text.append(t("summary.avg_size", size=format_page_size(s.avg_page_size_bytes)))
|
|
191
|
+
text.append("\n\n")
|
|
192
|
+
|
|
193
|
+
# Groesste Seiten.
|
|
194
|
+
if s.biggest_pages:
|
|
195
|
+
text.append(t("summary.biggest_pages"), style="bold underline")
|
|
196
|
+
text.append("\n")
|
|
197
|
+
# Erste Bar-Zeile liegt auf dem aktuellen Zeilenindex (= Anzahl der
|
|
198
|
+
# bisher abgeschlossenen Zeilen). Jede Bar-Zeile -> eine URL.
|
|
199
|
+
start = text.plain.count("\n")
|
|
200
|
+
for i, p in enumerate(s.biggest_pages):
|
|
201
|
+
self._line_urls[start + i] = p.url
|
|
202
|
+
rows: list[tuple[int, str, str | Text]] = [
|
|
203
|
+
(
|
|
204
|
+
p.page_size_bytes,
|
|
205
|
+
format_page_size(p.page_size_bytes),
|
|
206
|
+
self._link_path(_short_url(p.url), p.url),
|
|
207
|
+
)
|
|
208
|
+
for p in s.biggest_pages
|
|
209
|
+
]
|
|
210
|
+
text.append_text(render_bars(rows, bar_style="magenta"))
|
|
211
|
+
text.append("\n\n")
|
|
212
|
+
|
|
213
|
+
# Big Fische (groesste Einzelressourcen).
|
|
214
|
+
if s.biggest_resources:
|
|
215
|
+
text.append(t("summary.big_fish"), style="bold underline")
|
|
216
|
+
text.append("\n")
|
|
217
|
+
start = text.plain.count("\n")
|
|
218
|
+
for i, res in enumerate(s.biggest_resources):
|
|
219
|
+
self._line_urls[start + i] = res.url
|
|
220
|
+
res_rows: list[tuple[int, str, str | Text]] = []
|
|
221
|
+
for res in s.biggest_resources:
|
|
222
|
+
label = Text(f"{(res.resource_type or '?'):<10} ", style="dim")
|
|
223
|
+
label.append_text(self._link_path(_short_url(res.url), res.url))
|
|
224
|
+
res_rows.append((res.size_bytes, format_page_size(res.size_bytes), label))
|
|
225
|
+
text.append_text(render_bars(res_rows, bar_style="cyan"))
|
|
226
|
+
|
|
227
|
+
return text
|
|
228
|
+
|
|
229
|
+
@on(Button.Pressed, "#summary-close")
|
|
230
|
+
def _on_close_button(self) -> None:
|
|
231
|
+
self.dismiss(None)
|
|
232
|
+
|
|
233
|
+
def action_close(self) -> None:
|
|
234
|
+
self.dismiss(None)
|