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,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)