termainer 0.4.0__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.
@@ -0,0 +1,837 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import socket
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from rich.markup import escape
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Horizontal, Vertical
13
+ from textual.events import Resize
14
+ from textual.screen import ModalScreen, Screen
15
+ from textual.widgets import Button, Footer, Input, Label, ListView, Static, Select
16
+
17
+ from ..locale import _
18
+ from ..config import build_ssh_from_ssh_server, get_configured_ssh_servers
19
+ from ..providers.base import ContainerSummary, Provider
20
+ from ..providers.docker import DockerProvider
21
+ from ..remote.ssh import SSHConnection
22
+ from ..server_manager import ServerConnection, ServerManager
23
+ from ..utils.helpers import build_report_header, format_timestamp
24
+ from ..version import VERSION
25
+ from .widgets import ContainerItem, DetailsWidget, LogWidget, StatsWidget
26
+
27
+
28
+ REPORTS_DIR = Path("reports")
29
+
30
+
31
+ class Dashboard(Screen):
32
+ CSS_PATH = "styles.tcss"
33
+
34
+ BINDINGS = [
35
+ Binding("up", "focus_up", _("dashboard.bind.up"), show=True),
36
+ Binding("down", "focus_down", _("dashboard.bind.down"), show=True),
37
+ Binding("enter", "select_container", _("dashboard.bind.select"), show=True),
38
+ Binding("f5", "refresh_list", _("dashboard.bind.refresh"), show=True, priority=True),
39
+ Binding("p", "toggle_pause", _("dashboard.bind.pause_logs"), show=True, priority=True),
40
+ Binding("e", "export_logs", _("dashboard.bind.export"), show=True, priority=True),
41
+ Binding("a", "start_container", _("dashboard.bind.start"), show=True, priority=True),
42
+ Binding("t", "stop_container", _("dashboard.bind.stop"), show=True, priority=True),
43
+ Binding("r", "restart_container", _("dashboard.bind.restart"), show=True, priority=True),
44
+ Binding("delete", "confirm_remove", _("dashboard.bind.delete"), show=True, priority=True),
45
+ Binding("o", "restart_policy", _("dashboard.bind.restart_policy"), show=True, priority=True),
46
+ Binding("c", "exec_cmd", _("dashboard.bind.exec"), show=True, priority=True),
47
+ Binding("escape", "back_to_environment", _("dashboard.bind.back"), show=True, priority=True),
48
+ Binding("q", "quit", _("dashboard.bind.quit"), show=True, priority=True),
49
+ ]
50
+
51
+ def __init__(
52
+ self,
53
+ server_manager: ServerManager,
54
+ server_label: Optional[str] = None,
55
+ root_server_manager: Optional[ServerManager] = None,
56
+ ) -> None:
57
+ super().__init__()
58
+ self._server_manager = server_manager
59
+ self._root_server_manager = root_server_manager or server_manager
60
+ self._active_server = server_label
61
+ self._selected_container: Optional[str] = None
62
+ self._selected_info: ContainerSummary = {}
63
+ self._selected_server: Optional[str] = None
64
+ self._log_task: Optional[asyncio.Task] = None
65
+ self._stats_task: Optional[asyncio.Task] = None
66
+ self._compact_mode = False
67
+ self._ultra_compact_mode = False
68
+ self._active_ssh_conn: Optional[SSHConnection] = None # current SSH connection
69
+ self._saved_docker_host: Optional[str] = None
70
+ self._refresh_request_id = 0
71
+
72
+ @property
73
+ def _active_provider(self) -> Optional[Provider]:
74
+ if self._active_server:
75
+ try:
76
+ return self._server_manager.get_provider(self._active_server)
77
+ except KeyError:
78
+ pass
79
+ return self._server_manager.servers[0].provider if self._server_manager.servers else None
80
+
81
+ @property
82
+ def _is_kubernetes(self) -> bool:
83
+ prov = self._active_provider
84
+ return prov.name in {"kubernetes", "openshift"} if prov else False
85
+
86
+ @property
87
+ def _is_single_server(self) -> bool:
88
+ return self._active_server is not None
89
+
90
+ def compose(self) -> ComposeResult:
91
+ resource_label = _("dashboard.resource.pods") if self._is_kubernetes else _("dashboard.resource.containers")
92
+
93
+ # Build server selector options if multi-server setup
94
+ server_selector = self._build_server_selector()
95
+
96
+ sidebar_children: list = [
97
+ Static(f"[bold white]› {resource_label}[/] [dim]0[/]", classes="panel-header", id="sidebar-count"),
98
+ ]
99
+
100
+ if server_selector is not None:
101
+ sidebar_children.append(server_selector)
102
+
103
+ server_info = self._build_server_info_label()
104
+ if server_info is not None:
105
+ sidebar_children.append(server_info)
106
+
107
+ sidebar_children.extend([
108
+ Input(placeholder=_("dashboard.search.placeholder", resource=resource_label.lower()), id="search-input"),
109
+ ListView(id="container-list"),
110
+ ])
111
+
112
+ yield Vertical(
113
+ self._top_bar(),
114
+ Horizontal(
115
+ Vertical(*sidebar_children, id="sidebar"),
116
+ Vertical(
117
+ self._top_panels(),
118
+ self._logs_panel(),
119
+ id="workspace",
120
+ ),
121
+ id="dashboard-body",
122
+ ),
123
+ id="dashboard-root",
124
+ )
125
+ yield Footer()
126
+
127
+ def _top_bar(self) -> Vertical:
128
+ provider_name = self._active_provider.name.capitalize() if self._active_provider else ""
129
+ mode_suffix = " [dim](ultra)[/]" if self._ultra_compact_mode else (" [dim](compact)[/]" if self._compact_mode else "")
130
+ connection_status = (
131
+ _("dashboard.status.connected", provider=provider_name) + mode_suffix
132
+ if self._active_provider
133
+ else _("dashboard.status.disconnected") + mode_suffix
134
+ )
135
+
136
+ brand_row = Horizontal(
137
+ Static(f"[bold #22d3ee][ ][/] [bold #22d3ee]TERMAINER[/] [#5c5c5c]v{VERSION}[/]", id="top-brand"),
138
+ Static(_("dashboard.tagline"), id="top-tagline"),
139
+ Static(connection_status, id="top-status"),
140
+ id="top-bar-row",
141
+ )
142
+
143
+ return Vertical(brand_row, id="top-bar")
144
+
145
+ def _build_server_selector(self) -> Optional[Select]:
146
+ """Build a Select widget with local servers + SSH aliases from ~/.ssh/config.
147
+ No SSH connections are made here — just reads the config file."""
148
+ ssh_servers = get_configured_ssh_servers()
149
+ total = self._server_manager.server_count + len(ssh_servers)
150
+ if total <= 1 and not ssh_servers:
151
+ return None
152
+
153
+ options: list[tuple[str, str]] = []
154
+
155
+ # Local servers (already connected)
156
+ for label in self._server_manager.server_labels:
157
+ options.append((f"⬡ {label}", f"local:{label}"))
158
+
159
+ # SSH servers from ~/.ssh/config (no connection yet)
160
+ for alias, srv in ssh_servers.items():
161
+ options.append((f"⬢ {srv.display_name}", f"ssh:{alias}"))
162
+
163
+ if not options:
164
+ return None
165
+
166
+ return Select(options, id="server-selector", classes="server-selector",
167
+ value=f"local:{self._server_manager.server_labels[0]}" if self._server_manager.server_labels else Select.BLANK)
168
+
169
+ def on_select_changed(self, event: Select.Changed) -> None:
170
+ """Handle server selector changes."""
171
+ if event.control.id != "server-selector" or event.value is Select.BLANK:
172
+ return
173
+ self.run_worker(self._switch_server(str(event.value)))
174
+
175
+ def _build_server_info_label(self) -> Optional[Static]:
176
+ """Build a green label showing server hostname and IP/DNS."""
177
+ server = self._active_server or ""
178
+ if not server:
179
+ return None
180
+
181
+ if self._active_ssh_conn:
182
+ host = self._active_ssh_conn.host
183
+ return Static(_("dashboard.server_info.ssh", server=escape(server), host=escape(host)), id="server-info")
184
+
185
+ hostname = socket.gethostname()
186
+ ip = self._get_local_ip()
187
+ return Static(_("dashboard.server_info.local", hostname=escape(hostname), ip=escape(ip)), id="server-info")
188
+
189
+ @staticmethod
190
+ def _get_local_ip() -> str:
191
+ """Get the local IP address that can reach the network."""
192
+ try:
193
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
194
+ s.connect(("8.8.8.8", 80))
195
+ ip = s.getsockname()[0]
196
+ s.close()
197
+ return ip
198
+ except Exception:
199
+ return "127.0.0.1"
200
+
201
+ async def _switch_server(self, value: str) -> None:
202
+ """Switch active server. For SSH servers, open an SSH tunnel to forward
203
+ the remote Docker socket locally so all docker commands run locally."""
204
+ self._cancel_tasks()
205
+ self._selected_container = None
206
+ self._selected_info = {}
207
+ self._selected_server = None
208
+
209
+ # Close previous tunnel if any
210
+ if self._active_ssh_conn:
211
+ await self._active_ssh_conn.close_tunnel()
212
+ self._active_ssh_conn = None
213
+ self._restore_docker_host()
214
+
215
+ if value.startswith("local:"):
216
+ label = value[len("local:"):]
217
+ self._active_server = label
218
+
219
+ elif value.startswith("ssh:"):
220
+ alias = value[len("ssh:"):]
221
+ ssh_servers = get_configured_ssh_servers()
222
+ if alias not in ssh_servers:
223
+ self.notify(_("dashboard.notify.server_not_found", alias=alias), severity="error")
224
+ return
225
+
226
+ ssh_server = ssh_servers[alias]
227
+ ssh_conn = build_ssh_from_ssh_server(ssh_server)
228
+
229
+ try:
230
+ tunnel_socket = await ssh_conn.create_tunnel()
231
+ except RuntimeError as e:
232
+ self.notify(_("dashboard.notify.ssh_error", alias=alias, e=str(e)), severity="error")
233
+ return
234
+
235
+ self._active_ssh_conn = ssh_conn
236
+ self._saved_docker_host = os.environ.get("DOCKER_HOST")
237
+ os.environ["DOCKER_HOST"] = f"unix://{tunnel_socket}"
238
+ provider = DockerProvider()
239
+
240
+ existing = [s for s in self._server_manager.servers if s.label == alias]
241
+ if existing:
242
+ self._server_manager.servers.remove(existing[0])
243
+ self._server_manager.servers.append(
244
+ ServerConnection(label=alias, provider=provider, ssh=ssh_conn)
245
+ )
246
+ self._active_server = alias
247
+
248
+ await self._refresh_containers()
249
+ self._update_server_info()
250
+
251
+ def _update_server_info(self) -> None:
252
+ """Update the server info label after switching servers."""
253
+ try:
254
+ info_label = self.query_one("#server-info", Static)
255
+ new_label = self._build_server_info_label()
256
+ if new_label:
257
+ info_label.update(new_label.renderable)
258
+ except Exception:
259
+ pass
260
+
261
+ def _top_panels(self) -> Horizontal:
262
+ resource_singular = _("dashboard.resource.pod") if self._is_kubernetes else _("dashboard.resource.container")
263
+ details_label = _("dashboard.details.header_pod") if self._is_kubernetes else _("dashboard.details.header_container")
264
+ return Horizontal(
265
+ Vertical(
266
+ Static(f"[bold white]› {details_label}[/]", classes="panel-header", id="details-header"),
267
+ DetailsWidget(_("dashboard.details.placeholder", resource=resource_singular), id="details-content"),
268
+ id="details-panel",
269
+ ),
270
+ Vertical(
271
+ Static(f"[bold white]› {_('dashboard.stats.header')}[/]", classes="panel-header"),
272
+ StatsWidget(id="stats-content"),
273
+ id="stats-panel",
274
+ ),
275
+ id="top-panels",
276
+ )
277
+
278
+ def _logs_panel(self) -> Vertical:
279
+ return Vertical(
280
+ Horizontal(
281
+ Static(f"[bold white]› {_('dashboard.logs.header')}[/]", id="logs-title"),
282
+ Static(f"[bold #4ade80]{_('dashboard.logs.live')}[/]", id="logs-live"),
283
+ id="logs-header-row",
284
+ ),
285
+ LogWidget(id="log-content"),
286
+ id="logs-panel",
287
+ )
288
+
289
+
290
+ def on_mount(self) -> None:
291
+ self._apply_responsive_mode(self.size.width, self.size.height)
292
+ self.run_worker(self._refresh_containers())
293
+
294
+ def on_resize(self, event: Resize) -> None:
295
+ self._apply_responsive_mode(event.size.width, event.size.height)
296
+
297
+ def _apply_responsive_mode(self, width: int, height: int) -> None:
298
+ compact = width < 180 or height < 48
299
+ ultra_compact = width < 125 or height < 36
300
+ self._compact_mode = compact
301
+ self._ultra_compact_mode = ultra_compact
302
+
303
+ root = self.query_one("#dashboard-root", Vertical)
304
+ if compact:
305
+ root.add_class("compact")
306
+ else:
307
+ root.remove_class("compact")
308
+
309
+ if ultra_compact:
310
+ root.add_class("ultra-compact")
311
+ else:
312
+ root.remove_class("ultra-compact")
313
+
314
+ status = self.query_one("#top-status", Static)
315
+ provider_name = self._active_provider.name.capitalize() if self._active_provider else ""
316
+ mode_suffix = " [dim](ultra)[/]" if ultra_compact else (" [dim](compact)[/]" if compact else "")
317
+ if self._active_provider:
318
+ status.update(_("dashboard.status.connected", provider=provider_name) + mode_suffix)
319
+ else:
320
+ status.update(_("dashboard.status.disconnected") + mode_suffix)
321
+
322
+ async def _refresh_containers(self) -> None:
323
+ self._refresh_request_id += 1
324
+ request_id = self._refresh_request_id
325
+ list_view = self.query_one("#container-list", ListView)
326
+ try:
327
+ if self._is_single_server:
328
+ provider = self._server_manager.get_provider(self._active_server)
329
+ containers = await provider.list_containers()
330
+ for c in containers:
331
+ c["_server"] = self._active_server
332
+ else:
333
+ containers = await self._server_manager.list_all_containers()
334
+
335
+ # If another refresh started while this one was awaiting I/O, ignore stale results.
336
+ if request_id != self._refresh_request_id:
337
+ return
338
+
339
+ list_view.clear()
340
+
341
+ for c in containers:
342
+ list_view.append(ContainerItem(c))
343
+ if containers:
344
+ list_view.index = 0
345
+ list_view.focus()
346
+ first = list_view.highlighted_child
347
+ if isinstance(first, ContainerItem):
348
+ await self._select_container(first)
349
+ count_label = self.query_one("#sidebar-count", Static)
350
+ resource_label = _("dashboard.resource.pods") if self._is_kubernetes else _("dashboard.resource.containers")
351
+ server_count = self._server_manager.server_count
352
+ suffix = _("dashboard.sidebar.multi_server", count=str(server_count)) if not self._is_single_server and server_count > 1 else ""
353
+ count_label.update(f"[bold white]› {resource_label}[/] [dim]{len(containers)}{suffix}[/]")
354
+ except Exception as e:
355
+ self.notify(_("dashboard.notify.list_error", e=str(e)), severity="error")
356
+
357
+ def on_input_changed(self, event: Input.Changed) -> None:
358
+ query = event.value.lower()
359
+ list_view = self.query_one("#container-list", ListView)
360
+ for item in list_view.children:
361
+ if isinstance(item, ContainerItem):
362
+ name = item.container.get("names") or item.container.get("name") or item.container.get("id", "")
363
+ if isinstance(name, list):
364
+ name = name[0]
365
+ item.display = query in str(name).lower()
366
+
367
+ async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
368
+ item = event.item
369
+ if isinstance(item, ContainerItem):
370
+ await self._select_container(item)
371
+
372
+ async def on_list_view_selected(self, event: ListView.Selected) -> None:
373
+ item = event.item
374
+ if not isinstance(item, ContainerItem):
375
+ return
376
+ await self._select_container(item)
377
+
378
+ async def _select_container(self, item: ContainerItem) -> None:
379
+ cid = item.container.get("id", "")
380
+ if cid and cid == self._selected_container:
381
+ return
382
+ self._selected_container = cid
383
+ self._selected_info = item.container
384
+ self._selected_server = item.server_label
385
+ await self._update_panels(cid, item.container)
386
+
387
+ async def _update_panels(self, container_id: str, container_info: ContainerSummary) -> None:
388
+ provider = self._provider_for(container_info)
389
+ if provider is None:
390
+ return
391
+
392
+ details = self.query_one("#details-content", DetailsWidget)
393
+ log_widget = self.query_one("#log-content", LogWidget)
394
+ logs_title = self.query_one("#logs-title", Static)
395
+
396
+ name = container_info.get("names") or container_info.get("name") or container_id
397
+ if isinstance(name, list):
398
+ name = name[0]
399
+ logs_title.update(_("dashboard.logs.title", name=escape(str(name))))
400
+
401
+ log_widget.clear()
402
+ stats_widget = self.query_one("#stats-content", StatsWidget)
403
+ stats_widget.reset_history()
404
+ self._cancel_tasks()
405
+
406
+ try:
407
+ env = await provider.get_env(container_id)
408
+ details.show_details(container_info, env)
409
+ except Exception:
410
+ details.update(f"[red]{_('dashboard.notify.details_error')}[/]")
411
+
412
+ self._stats_task = asyncio.create_task(self._stream_stats(container_id))
413
+ self._log_task = asyncio.create_task(self._stream_logs(container_id))
414
+
415
+ def _provider_for(self, container: ContainerSummary) -> Optional[Provider]:
416
+ server_label = container.get("_server") or self._active_server
417
+ if server_label:
418
+ try:
419
+ return self._server_manager.get_provider(server_label)
420
+ except KeyError:
421
+ pass
422
+ if self._server_manager.servers:
423
+ return self._server_manager.servers[0].provider
424
+ return None
425
+
426
+ async def _stream_stats(self, container_id: str) -> None:
427
+ provider = self._provider_for(self._selected_info)
428
+ if provider is None:
429
+ return
430
+ stats_widget = self.query_one("#stats-content", StatsWidget)
431
+ try:
432
+ async for stat in provider.stats(container_id):
433
+ stats_widget.update_stats(stat)
434
+ except Exception:
435
+ pass
436
+
437
+ async def _stream_logs(self, container_id: str) -> None:
438
+ provider = self._provider_for(self._selected_info)
439
+ if provider is None:
440
+ return
441
+ log_widget = self.query_one("#log-content", LogWidget)
442
+ try:
443
+ async for line in provider.logs(container_id, tail=100, follow=True):
444
+ log_widget.append_line(line)
445
+ except Exception:
446
+ log_widget.append_line(f"[dim]{_('dashboard.notify.log_stream_ended')}[/]")
447
+
448
+ def _cancel_tasks(self) -> None:
449
+ for task in (self._log_task, self._stats_task):
450
+ if task and not task.done():
451
+ task.cancel()
452
+ self._log_task = None
453
+ self._stats_task = None
454
+
455
+ def action_focus_up(self) -> None:
456
+ self._move_list_selection(-1)
457
+
458
+ def action_focus_down(self) -> None:
459
+ self._move_list_selection(1)
460
+
461
+ def _move_list_selection(self, delta: int) -> None:
462
+ list_view = self.query_one("#container-list", ListView)
463
+ visible_items: list[tuple[int, ContainerItem]] = []
464
+ for idx, child in enumerate(list_view.children):
465
+ if isinstance(child, ContainerItem) and child.display:
466
+ visible_items.append((idx, child))
467
+
468
+ if not visible_items:
469
+ return
470
+
471
+ highlighted = list_view.highlighted_child
472
+ current_pos = 0
473
+ if isinstance(highlighted, ContainerItem):
474
+ for pos, (_, item) in enumerate(visible_items):
475
+ if item is highlighted:
476
+ current_pos = pos
477
+ break
478
+
479
+ new_pos = (current_pos + delta) % len(visible_items)
480
+ new_index, _ = visible_items[new_pos]
481
+ list_view.index = new_index
482
+
483
+ def action_select_container(self) -> None:
484
+ list_view = self.query_one("#container-list", ListView)
485
+ if list_view.highlighted_child and isinstance(list_view.highlighted_child, ContainerItem):
486
+ self.run_worker(self._select_container(list_view.highlighted_child))
487
+
488
+ async def action_refresh_list(self) -> None:
489
+ await self._refresh_containers()
490
+
491
+ async def _run_container_action(self, action: str, success_message: str, **kwargs: object) -> None:
492
+ target_container, target_info, target_server = self._current_container_target()
493
+ if not target_container or not target_server:
494
+ self.notify(_("dashboard.notify.select_container"), severity="warning", timeout=3)
495
+ return
496
+ try:
497
+ provider = self._server_manager.get_provider(target_server)
498
+ await getattr(provider, action)(target_container, **kwargs)
499
+ self._selected_container = target_container
500
+ self._selected_info = target_info
501
+ self._selected_server = target_server
502
+ self.notify(success_message, timeout=3)
503
+ await self._refresh_containers()
504
+ except Exception as e:
505
+ self.notify(_("dashboard.notify.action_error", action=action, e=str(e)), severity="error", timeout=5)
506
+ except Exception as e:
507
+ self.notify(_("dashboard.notify.action_error", action=action, e=str(e)), severity="error", timeout=5)
508
+
509
+ async def action_start_container(self) -> None:
510
+ await self._run_container_action("start", _("dashboard.action.start_success"))
511
+
512
+ async def action_stop_container(self) -> None:
513
+ await self._run_container_action("stop", _("dashboard.action.stop_success"))
514
+
515
+ async def action_restart_container(self) -> None:
516
+ await self._run_container_action("restart", _("dashboard.action.restart_success"))
517
+
518
+ def action_confirm_remove(self) -> None:
519
+ target_container, target_info, target_server = self._current_container_target()
520
+ if not target_container:
521
+ self.notify(_("dashboard.notify.select_container_remove"), severity="warning", timeout=3)
522
+ return
523
+ self._selected_container = target_container
524
+ self._selected_info = target_info
525
+ self._selected_server = target_server
526
+ container_name = self._container_name(target_info, target_container)
527
+ resource_name = _("dashboard.resource.pod") if self._is_kubernetes else _("dashboard.resource.container")
528
+ self.app.push_screen(
529
+ RemoveContainerModal(container_name=container_name, container_id=target_container, resource_name=resource_name),
530
+ self._remove_container,
531
+ )
532
+
533
+ async def _remove_container(self, confirmed: bool) -> None:
534
+ if not confirmed or not self._selected_container:
535
+ return
536
+ await self._run_container_action("remove", _("dashboard.action.remove_success"), force=True)
537
+ self._selected_container = None
538
+ self._selected_info = {}
539
+ self._selected_server = None
540
+ resource_singular = _("dashboard.resource.pod") if self._is_kubernetes else _("dashboard.resource.container")
541
+ self.query_one("#details-content", DetailsWidget).update(_("dashboard.details.placeholder", resource=resource_singular))
542
+ self.query_one("#log-content", LogWidget).clear()
543
+
544
+ def action_toggle_pause(self) -> None:
545
+ log_widget = self.query_one("#log-content", LogWidget)
546
+ log_widget.toggle_pause()
547
+ live = self.query_one("#logs-live", Static)
548
+ if log_widget.paused:
549
+ live.update(f"[bold #fbbf24]{_('dashboard.logs.paused')}[/]")
550
+ else:
551
+ live.update(f"[bold #4ade80]{_('dashboard.logs.live')}[/]")
552
+ status = _("dashboard.logs.status_paused") if log_widget.paused else _("dashboard.logs.status_resumed")
553
+ self.notify(_("dashboard.logs.toggle_notify", status=status), timeout=2)
554
+
555
+ def action_export_logs(self) -> None:
556
+ log_widget = self.query_one("#log-content", LogWidget)
557
+ content = log_widget.get_content()
558
+ if not content.strip():
559
+ self.notify(_("dashboard.notify.no_logs"), severity="warning", timeout=3)
560
+ return
561
+
562
+ cid = self._selected_container or "unknown"
563
+ container_name = self._selected_info.get("names") or self._selected_info.get("name") or cid
564
+ if isinstance(container_name, list):
565
+ container_name = container_name[0]
566
+ image = self._selected_info.get("image", "unknown")
567
+
568
+ header = build_report_header(
569
+ container_name=str(container_name),
570
+ image=str(image),
571
+ provider=self._active_provider.name if self._active_provider else "?",
572
+ )
573
+ report = header + content
574
+
575
+ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
576
+ filename = f"reporte_bug_{format_timestamp()}.txt"
577
+ filepath = REPORTS_DIR / filename
578
+
579
+ filepath.write_text(report, encoding="utf-8")
580
+ self.notify(_("dashboard.notify.logs_exported", filepath=str(filepath)), timeout=5)
581
+
582
+ def action_quit(self) -> None:
583
+ self._cancel_tasks()
584
+ self._close_active_tunnel()
585
+ self.app.exit()
586
+
587
+ def action_restart_policy(self) -> None:
588
+ target_container, target_info, target_server = self._current_container_target()
589
+ if not target_container:
590
+ self.notify(_("dashboard.notify.select_container_policy"), severity="warning", timeout=3)
591
+ return
592
+ self._selected_container = target_container
593
+ self._selected_info = target_info
594
+ self._selected_server = target_server
595
+ try:
596
+ provider = self._server_manager.get_provider(target_server)
597
+ except (KeyError, TypeError):
598
+ provider = self._active_provider
599
+ container_name = self._container_name(target_info, target_container)
600
+ current_policy = str(target_info.get("restartpolicy", target_info.get("restart", "")))
601
+ self.app.push_screen(
602
+ RestartPolicyModal(target_container, container_name, current_policy, provider.name),
603
+ self._apply_restart_policy,
604
+ )
605
+
606
+ async def _apply_restart_policy(self, policy: Optional[str]) -> None:
607
+ if not policy or not self._selected_container:
608
+ return
609
+ try:
610
+ provider = self._server_manager.get_provider(self._selected_server)
611
+ await provider.set_restart_policy(self._selected_container, policy)
612
+ self.notify(_("dashboard.notify.policy_changed", policy=policy), timeout=3)
613
+ await self._refresh_containers()
614
+ except Exception as e:
615
+ self.notify(_("dashboard.notify.generic_error", e=str(e)), severity="error", timeout=8)
616
+
617
+ def action_exec_cmd(self) -> None:
618
+ target_container, target_info, target_server = self._current_container_target()
619
+ if not target_container:
620
+ self.notify(_("dashboard.notify.select_container_exec"), severity="warning", timeout=3)
621
+ return
622
+ try:
623
+ provider = self._server_manager.get_provider(target_server)
624
+ except (KeyError, TypeError):
625
+ provider = self._active_provider
626
+ container_name = self._container_name(target_info, target_container)
627
+ self.app.push_screen(ExecModal(target_container, container_name, provider))
628
+
629
+ def action_back_to_environment(self) -> None:
630
+ self._cancel_tasks()
631
+ self._close_active_tunnel()
632
+ from .environment import EnvironmentScreen
633
+ self.app.switch_screen(EnvironmentScreen(self._root_server_manager))
634
+
635
+ def _close_active_tunnel(self) -> None:
636
+ """Close the active SSH tunnel and restore DOCKER_HOST."""
637
+ if self._active_ssh_conn:
638
+ try:
639
+ asyncio.create_task(self._active_ssh_conn.close_tunnel())
640
+ except Exception:
641
+ pass
642
+ self._active_ssh_conn = None
643
+ self._restore_docker_host()
644
+
645
+ def _restore_docker_host(self) -> None:
646
+ """Restore the original DOCKER_HOST value before the tunnel was created."""
647
+ if self._saved_docker_host is not None:
648
+ os.environ["DOCKER_HOST"] = self._saved_docker_host
649
+ self._saved_docker_host = None
650
+ else:
651
+ os.environ.pop("DOCKER_HOST", None)
652
+
653
+ @staticmethod
654
+ def _container_name(container: ContainerSummary, fallback: str) -> str:
655
+ name = container.get("names") or container.get("name") or fallback
656
+ if isinstance(name, list):
657
+ name = name[0]
658
+ return str(name)
659
+
660
+ def _current_container_target(self) -> tuple[Optional[str], ContainerSummary, Optional[str]]:
661
+ if self._selected_container:
662
+ return self._selected_container, self._selected_info, self._selected_server
663
+ list_view = self.query_one("#container-list", ListView)
664
+ item = list_view.highlighted_child
665
+ if isinstance(item, ContainerItem):
666
+ return str(item.container.get("id", "")), item.container, item.server_label
667
+ return None, {}, None
668
+
669
+
670
+ class RemoveContainerModal(ModalScreen[bool]):
671
+ BINDINGS = [
672
+ ("escape", "cancel", _("dashboard.remove_modal.cancel")),
673
+ ("n", "cancel", _("dashboard.remove_modal.cancel")),
674
+ ("enter", "confirm", _("dashboard.remove_modal.confirm")),
675
+ ("y", "confirm", _("dashboard.remove_modal.confirm")),
676
+ ("left", "focus_prev", _("dashboard.bind.up")),
677
+ ("right", "focus_next", _("dashboard.bind.down")),
678
+ ("tab", "focus_next", _("dashboard.bind.down")),
679
+ ("shift+tab", "focus_prev", _("dashboard.bind.up")),
680
+ ]
681
+
682
+ def __init__(self, container_name: str, container_id: str, resource_name: str = "") -> None:
683
+ super().__init__()
684
+ self.container_name = container_name
685
+ self.container_id = container_id
686
+ self.resource_name = resource_name
687
+
688
+ def compose(self) -> ComposeResult:
689
+ yield Vertical(
690
+ Static(_("dashboard.remove_modal.title", resource=escape(self.resource_name)), id="remove-title"),
691
+ Label(_("dashboard.remove_modal.body", name=escape(self.container_name))),
692
+ Label(f"[dim]{escape(self.container_id)}[/]"),
693
+ Static(_("dashboard.remove_modal.warning"), id="remove-warning"),
694
+ Horizontal(
695
+ Button(_("dashboard.remove_modal.cancel"), id="cancel-remove"),
696
+ Button(_("dashboard.remove_modal.confirm"), variant="error", id="confirm-remove"),
697
+ id="remove-actions",
698
+ ),
699
+ id="remove-modal",
700
+ )
701
+
702
+ def on_button_pressed(self, event: Button.Pressed) -> None:
703
+ if event.button.id == "confirm-remove":
704
+ self.dismiss(True)
705
+ else:
706
+ self.dismiss(False)
707
+
708
+ def on_mount(self) -> None:
709
+ self.query_one("#cancel-remove", Button).focus()
710
+
711
+ def action_cancel(self) -> None:
712
+ self.dismiss(False)
713
+
714
+ def action_confirm(self) -> None:
715
+ self.dismiss(True)
716
+
717
+ def action_focus_next(self) -> None:
718
+ focused = self.focused
719
+ if focused and focused.id == "cancel-remove":
720
+ self.query_one("#confirm-remove", Button).focus()
721
+ else:
722
+ self.query_one("#cancel-remove", Button).focus()
723
+
724
+ def action_focus_prev(self) -> None:
725
+ self.action_focus_next()
726
+
727
+
728
+ class RestartPolicyModal(ModalScreen):
729
+ BINDINGS = [
730
+ Binding("escape", "cancel", _("dashboard.policy_modal.cancel")),
731
+ ]
732
+
733
+ _POLICIES: dict = {
734
+ "docker": ["no", "always", "on-failure", "unless-stopped"],
735
+ "podman": ["no", "always", "on-failure", "unless-stopped"],
736
+ "kubernetes": ["Always", "OnFailure", "Never"],
737
+ "openshift": ["Always", "OnFailure", "Never"],
738
+ "swarm": ["none", "on-failure", "any"],
739
+ }
740
+
741
+ def __init__(self, container_id: str, container_name: str, current_policy: str, provider_name: str) -> None:
742
+ super().__init__()
743
+ self._container_id = container_id
744
+ self._container_name = container_name
745
+ self._current_policy = current_policy
746
+ self._provider_name = provider_name
747
+ self._policies = self._POLICIES.get(provider_name, self._POLICIES["docker"])
748
+
749
+ def compose(self) -> ComposeResult:
750
+ options = [(p, p) for p in self._policies]
751
+ current = self._current_policy if self._current_policy in self._policies else self._policies[0]
752
+ yield Vertical(
753
+ Static(f"[bold #22d3ee]{_('dashboard.policy_modal.title')}[/]", id="policy-title"),
754
+ Static(_("dashboard.policy_modal.container", name=escape(self._container_name))),
755
+ Static(_("dashboard.policy_modal.current", policy=escape(self._current_policy or _("dashboard.policy_modal.undefined")))),
756
+ Select(options, value=current, id="policy-select"),
757
+ Horizontal(
758
+ Button(_("dashboard.policy_modal.cancel"), id="cancel-policy"),
759
+ Button(_("dashboard.policy_modal.apply"), variant="success", id="apply-policy"),
760
+ id="policy-actions",
761
+ ),
762
+ id="policy-modal",
763
+ )
764
+
765
+ def on_mount(self) -> None:
766
+ self.query_one("#policy-select", Select).focus()
767
+
768
+ def on_button_pressed(self, event: Button.Pressed) -> None:
769
+ if event.button.id == "apply-policy":
770
+ select = self.query_one("#policy-select", Select)
771
+ value = select.value
772
+ self.dismiss(str(value) if value is not Select.BLANK else None)
773
+ else:
774
+ self.dismiss(None)
775
+
776
+ def action_cancel(self) -> None:
777
+ self.dismiss(None)
778
+
779
+
780
+ class ExecModal(ModalScreen):
781
+ BINDINGS = [
782
+ Binding("escape", "close_exec", _("dashboard.exec_modal.close")),
783
+ ]
784
+
785
+ def __init__(self, container_id: str, container_name: str, provider) -> None:
786
+ super().__init__()
787
+ self._container_id = container_id
788
+ self._container_name = container_name
789
+ self._provider = provider
790
+
791
+ def compose(self) -> ComposeResult:
792
+ yield Vertical(
793
+ Static(
794
+ _("dashboard.exec_modal.title", name=escape(self._container_name)),
795
+ id="exec-title",
796
+ ),
797
+ Horizontal(
798
+ Input(placeholder=_("dashboard.exec_modal.placeholder"), id="exec-input"),
799
+ Button(_("dashboard.exec_modal.run"), id="run-exec", variant="success"),
800
+ id="exec-input-row",
801
+ ),
802
+ LogWidget(id="exec-output"),
803
+ Button(_("dashboard.exec_modal.close"), id="close-exec-btn"),
804
+ id="exec-modal",
805
+ )
806
+
807
+ def on_mount(self) -> None:
808
+ self.query_one("#exec-input", Input).focus()
809
+
810
+ def on_input_submitted(self, event: Input.Submitted) -> None:
811
+ if event.input.id == "exec-input":
812
+ self._launch(event.value.strip())
813
+
814
+ def on_button_pressed(self, event: Button.Pressed) -> None:
815
+ if event.button.id == "run-exec":
816
+ self._launch(self.query_one("#exec-input", Input).value.strip())
817
+ elif event.button.id == "close-exec-btn":
818
+ self.dismiss()
819
+
820
+ def _launch(self, command: str) -> None:
821
+ if not command:
822
+ return
823
+ self.run_worker(self._run_command(command))
824
+
825
+ async def _run_command(self, command: str) -> None:
826
+ output = self.query_one("#exec-output", LogWidget)
827
+ output.clear()
828
+ output.append_line(_("dashboard.exec_modal.prompt", command=escape(command)))
829
+ try:
830
+ async for line in self._provider.exec_command(self._container_id, command):
831
+ output.append_line(escape(line) if line else "")
832
+ output.append_line(f"[dim #5c5c5c]{_('dashboard.exec_modal.end')}[/]")
833
+ except Exception as e:
834
+ output.append_line(_("dashboard.exec_modal.error", error=escape(str(e))))
835
+
836
+ def action_close_exec(self) -> None:
837
+ self.dismiss()