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.
- termainer/__init__.py +0 -0
- termainer/app.py +242 -0
- termainer/config.py +82 -0
- termainer/config_manager.py +75 -0
- termainer/locale.py +460 -0
- termainer/providers/__init__.py +0 -0
- termainer/providers/base.py +61 -0
- termainer/providers/docker.py +213 -0
- termainer/providers/kubernetes.py +239 -0
- termainer/providers/openshift.py +77 -0
- termainer/providers/podman.py +158 -0
- termainer/providers/swarm.py +211 -0
- termainer/remote/__init__.py +0 -0
- termainer/remote/ssh.py +157 -0
- termainer/server_manager.py +84 -0
- termainer/ssh_config.py +138 -0
- termainer/ui/__init__.py +0 -0
- termainer/ui/dashboard.py +837 -0
- termainer/ui/environment.py +300 -0
- termainer/ui/home.py +263 -0
- termainer/ui/splash.py +89 -0
- termainer/ui/widgets.py +335 -0
- termainer/utils/__init__.py +0 -0
- termainer/utils/helpers.py +56 -0
- termainer/version.py +1 -0
- termainer-0.4.0.dist-info/METADATA +419 -0
- termainer-0.4.0.dist-info/RECORD +30 -0
- termainer-0.4.0.dist-info/WHEEL +5 -0
- termainer-0.4.0.dist-info/entry_points.txt +2 -0
- termainer-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|