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,335 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import deque
5
+ from typing import Dict, List
6
+
7
+ from rich.markup import escape
8
+ from rich.text import Text
9
+ from textual.app import ComposeResult
10
+ from textual.widgets import Label, ListItem, Static
11
+ from textual.widgets._rich_log import RichLog
12
+
13
+ from ..locale import _
14
+ from ..providers.base import ContainerSummary
15
+
16
+
17
+ DEFAULT_CHART_WIDTH = 62
18
+
19
+
20
+ def _status_color(status: str) -> str:
21
+ normalized = status.lower()
22
+ if "restart" in normalized:
23
+ return "#fbbf24"
24
+ if "exited" in normalized or "dead" in normalized or "error" in normalized:
25
+ return "#f87171"
26
+ if "created" in normalized or "stopped" in normalized or "paused" in normalized:
27
+ return "#6b5b8a"
28
+ if "up" in normalized or "running" in normalized:
29
+ return "#4ade80"
30
+ return "#6b5b8a"
31
+
32
+
33
+ class ContainerItem(ListItem):
34
+ def __init__(self, container: ContainerSummary) -> None:
35
+ self.container = container
36
+ self.server_label: str = container.get("_server", "")
37
+ super().__init__()
38
+
39
+ def compose(self) -> ComposeResult:
40
+ name = (
41
+ self.container.get("names")
42
+ or self.container.get("name")
43
+ or self.container.get("id", "unknown")
44
+ )
45
+ if isinstance(name, list):
46
+ name = name[0]
47
+ status = self.container.get("status", "")
48
+ namespace = self.container.get("namespace", "")
49
+ ready = self.container.get("ready", "")
50
+ status_text = str(status)
51
+ status_color = _status_color(status_text)
52
+
53
+ dot = f"[{status_color}]●[/]"
54
+ meta = f"{namespace} · {ready}" if namespace else ""
55
+ yield Label(f"{dot} [bold white]{escape(str(name))}[/]")
56
+ if meta:
57
+ yield Label(f" [dim]{escape(meta)}[/]")
58
+ else:
59
+ yield Label("")
60
+
61
+
62
+ class DetailsWidget(Static):
63
+ def __init__(self, *args, **kwargs) -> None:
64
+ super().__init__(*args, **kwargs)
65
+ self.can_focus = True
66
+ self.can_focus_children = False
67
+
68
+ def show_details(self, container: ContainerSummary, env: Dict[str, str]) -> None:
69
+ name = (
70
+ container.get("names")
71
+ or container.get("name")
72
+ or container.get("id", "unknown")
73
+ )
74
+ if isinstance(name, list):
75
+ name = name[0]
76
+ cid = container.get("id", "")
77
+ image = container.get("image", "")
78
+ status = container.get("status", "")
79
+ created = container.get("createdat", container.get("created", ""))
80
+ ports = container.get("ports", "")
81
+ networks = container.get("networks", "")
82
+ restart = container.get("restartpolicy", container.get("restart", ""))
83
+ namespace = container.get("namespace", "")
84
+ ready = container.get("ready", "")
85
+ node = container.get("node", "")
86
+
87
+ status_color = _status_color(str(status))
88
+
89
+ lines = [
90
+ f"[dim]◉[/] [bold #4ade80]{escape(str(name))}[/] [dim]{escape(str(cid)[:12])}[/]",
91
+ f" [bold white]{_('widgets.details.image')}[/] [white]{escape(str(image))}[/]",
92
+ f" [bold white]{_('widgets.details.status')}[/] [{status_color}]{escape(str(status))}[/]",
93
+ f" [bold white]{_('widgets.details.id')}[/] [white]{escape(str(cid))}[/]",
94
+ f" [bold white]{_('widgets.details.created')}[/] [white]{escape(str(created))}[/]",
95
+ f" [bold white]{_('widgets.details.ports')}[/] [#22d3ee]{escape(str(ports))}[/]",
96
+ f" [bold white]{_('widgets.details.networks')}[/] [#22d3ee]{escape(str(networks))}[/]",
97
+ f" [bold white]{_('widgets.details.restart')}[/] [white]{escape(str(restart))}[/]",
98
+ f" [bold white]{_('widgets.details.namespace')}[/] [#22d3ee]{escape(str(namespace))}[/]" if namespace else "",
99
+ f" [bold white]{_('widgets.details.ready')}[/] [white]{escape(str(ready))}[/]" if ready else "",
100
+ f" [bold white]{_('widgets.details.node')}[/] [white]{escape(str(node))}[/]" if node else "",
101
+ "",
102
+ ]
103
+
104
+ env_count = len(env)
105
+ lines.append(f" [bold #22d3ee]{_('widgets.details.env_title', count=str(env_count))}[/]")
106
+ if env:
107
+ for k, v in env.items():
108
+ display_val = "********" if "SECRET" in k.upper() or "PASSWORD" in k.upper() else v
109
+ lines.append(f" [#4ade80]{escape(str(k))}[/]=[white]{escape(str(display_val))}[/]")
110
+ else:
111
+ lines.append(_("widgets.details.env_empty"))
112
+
113
+ self.update("\n".join(lines))
114
+
115
+
116
+ class StatsWidget(Static):
117
+ def __init__(self, **kwargs) -> None:
118
+ super().__init__(**kwargs)
119
+ self._cpu_history: deque[float] = deque(maxlen=60)
120
+ self._mem_history: deque[float] = deque(maxlen=60)
121
+ self._net_history: deque[float] = deque(maxlen=60)
122
+ self._history_seeded = False
123
+
124
+ def update_stats(self, stats: dict) -> None:
125
+ cpu_raw = stats.get("cpu", stats.get("CPUPerc", "0%"))
126
+ mem_raw = stats.get("memory", stats.get("MemUsage", "0MiB / 0MiB"))
127
+ net_raw = stats.get("net_io", stats.get("NetIO", "0MB / 0MB"))
128
+ pids = stats.get("pids", stats.get("PIDs", "0"))
129
+
130
+ cpu_val = self._parse_percent(str(cpu_raw))
131
+ mem_val = self._parse_mem_pct(str(mem_raw))
132
+ net_val = self._parse_net(str(net_raw))
133
+
134
+ if not self._history_seeded:
135
+ self._seed_history(cpu_val, mem_val, net_val)
136
+ else:
137
+ self._cpu_history.append(cpu_val)
138
+ self._mem_history.append(mem_val)
139
+ self._net_history.append(net_val)
140
+
141
+ widget_width = max(50, int(getattr(self.size, "width", DEFAULT_CHART_WIDTH + 12) or (DEFAULT_CHART_WIDTH + 12)))
142
+ chart_width = max(34, min(DEFAULT_CHART_WIDTH, widget_width - 10))
143
+
144
+ cpu_chart = self._chart(self._cpu_history, 100, "100%", " 50%", " 0%", "green", chart_width)
145
+ mem_chart = self._chart(self._mem_history, 100, "100%", " 50%", " 0%", "cyan", chart_width)
146
+ net_max = max(max(self._net_history, default=0), 1)
147
+ net_chart = self._chart(self._net_history, net_max, self._format_bytes(net_max), self._format_bytes(net_max / 2), "0B", "magenta", chart_width)
148
+
149
+ cpu_card_w = 12 if widget_width >= 70 else 10
150
+ pids_card_w = 12 if widget_width >= 70 else 10
151
+ wide_card_w = 22 if widget_width >= 90 else 18
152
+
153
+ cpu_card = self._kpi_card(_("widgets.stats.cpu"), str(cpu_raw), "green", cpu_card_w)
154
+ mem_card = self._kpi_card(_("widgets.stats.memory"), str(mem_raw), "cyan", wide_card_w)
155
+ net_card = self._kpi_card(_("widgets.stats.net"), str(net_raw), "magenta", wide_card_w)
156
+ pids_card = self._kpi_card(_("widgets.stats.pids"), str(pids), "yellow", pids_card_w)
157
+
158
+ lines = [
159
+ *self._join_cards([cpu_card, mem_card, net_card, pids_card]),
160
+ "",
161
+ f"[bold white]{_('widgets.stats.cpu_chart')}[/]",
162
+ *cpu_chart,
163
+ "",
164
+ f"[bold white]{_('widgets.stats.mem_chart')}[/]",
165
+ *mem_chart,
166
+ "",
167
+ f"[bold white]{_('widgets.stats.net_chart')}[/]",
168
+ *net_chart,
169
+ ]
170
+ self.update("\n".join(lines))
171
+
172
+ def reset_history(self) -> None:
173
+ self._cpu_history.clear()
174
+ self._mem_history.clear()
175
+ self._net_history.clear()
176
+ self._history_seeded = False
177
+
178
+ def _seed_history(self, cpu: float, memory: float, network: float) -> None:
179
+ self._cpu_history.append(cpu)
180
+ self._mem_history.append(memory)
181
+ self._net_history.append(network)
182
+ self._history_seeded = True
183
+
184
+ @staticmethod
185
+ def _kpi_card(title: str, value: str, color: str, width: int) -> List[str]:
186
+ inner_width = width - 2
187
+ safe_title = escape(title[:inner_width]).center(inner_width)
188
+ safe_value = escape(value[:inner_width]).center(inner_width)
189
+ return [
190
+ f"[dim]╭{'─' * inner_width}╮[/]",
191
+ f"[dim]│[/][bold white]{safe_title}[/][dim]│[/]",
192
+ f"[dim]│[/][bold {color}]{safe_value}[/][dim]│[/]",
193
+ f"[dim]╰{'─' * inner_width}╯[/]",
194
+ ]
195
+
196
+ @staticmethod
197
+ def _join_cards(cards: List[List[str]]) -> List[str]:
198
+ return [" ".join(card[row] for card in cards) for row in range(len(cards[0]))]
199
+
200
+ @staticmethod
201
+ def _chart(data: deque, max_val: float, top_label: str, mid_label: str, bottom_label: str, color: str, chart_width: int) -> List[str]:
202
+ samples = list(data)[-chart_width:]
203
+ sparkline = StatsWidget._sparkline(samples, max_val).rjust(chart_width)
204
+ return [
205
+ f"[dim]{top_label:>5} ┌{'─' * chart_width}┐[/]",
206
+ f"[dim]{mid_label:>5} │[/][{color}]{sparkline}[/][dim]│[/]",
207
+ f"[dim]{bottom_label:>5} └{'─' * chart_width}┘[/]",
208
+ ]
209
+
210
+ @staticmethod
211
+ def _sparkline(samples: List[float], max_val: float) -> str:
212
+ if not samples:
213
+ return ""
214
+ chars = "▁▂▃▄▅▆▇█"
215
+ safe_max = max(max_val, max(samples), 1)
216
+ result = []
217
+ previous_index = 0
218
+ for index, value in enumerate(samples):
219
+ ratio = max(0.0, min(float(value) / safe_max, 1.0))
220
+ char_index = int(round(ratio * (len(chars) - 1)))
221
+ if value <= 0:
222
+ char_index = 0
223
+ if index and char_index == previous_index and value != samples[index - 1]:
224
+ char_index = min(len(chars) - 1, max(0, char_index + (1 if value > samples[index - 1] else -1)))
225
+ previous_index = char_index
226
+ result.append(chars[char_index])
227
+ return "".join(result)
228
+
229
+ @staticmethod
230
+ def _format_bytes(value: float) -> str:
231
+ units = ["B", "KB", "MB", "GB", "TB"]
232
+ amount = float(value)
233
+ unit_index = 0
234
+ while amount >= 1000 and unit_index < len(units) - 1:
235
+ amount /= 1000
236
+ unit_index += 1
237
+ if amount >= 10 or unit_index == 0:
238
+ return f"{amount:.0f}{units[unit_index]}"
239
+ return f"{amount:.1f}{units[unit_index]}"
240
+
241
+ @staticmethod
242
+ def _parse_percent(raw: str) -> float:
243
+ try:
244
+ return float(raw.strip().rstrip("%"))
245
+ except (ValueError, AttributeError):
246
+ return 0.0
247
+
248
+ @staticmethod
249
+ def _parse_mem_pct(raw: str) -> float:
250
+ try:
251
+ parts = raw.split("/")
252
+ if len(parts) == 2:
253
+ used = parts[0].strip()
254
+ total = parts[1].strip()
255
+ return (StatsWidget._mem_to_bytes(used) / StatsWidget._mem_to_bytes(total)) * 100
256
+ return 0.0
257
+ except Exception:
258
+ return 0.0
259
+
260
+ @staticmethod
261
+ def _mem_to_bytes(raw: str) -> float:
262
+ raw = raw.strip()
263
+ try:
264
+ if "GiB" in raw:
265
+ return float(raw.replace("GiB", "").strip()) * 1024 ** 3
266
+ if "MiB" in raw:
267
+ return float(raw.replace("MiB", "").strip()) * 1024 ** 2
268
+ if "KiB" in raw:
269
+ return float(raw.replace("KiB", "").strip()) * 1024
270
+ if "GB" in raw:
271
+ return float(raw.replace("GB", "").strip()) * 10 ** 9
272
+ if "MB" in raw:
273
+ return float(raw.replace("MB", "").strip()) * 10 ** 6
274
+ if "KB" in raw:
275
+ return float(raw.replace("KB", "").strip()) * 10 ** 3
276
+ return float(raw)
277
+ except (ValueError, AttributeError):
278
+ return 0.0
279
+
280
+ @staticmethod
281
+ def _parse_net(raw: str) -> float:
282
+ try:
283
+ parts = raw.split("/")
284
+ if len(parts) == 1:
285
+ return StatsWidget._mem_to_bytes(parts[0])
286
+ return StatsWidget._mem_to_bytes(parts[0])
287
+ except Exception:
288
+ return 0.0
289
+
290
+
291
+ class LogWidget(RichLog):
292
+ def __init__(self, **kwargs) -> None:
293
+ super().__init__(max_lines=500, highlight=True, wrap=True, **kwargs)
294
+ self._buffer: List[str] = []
295
+ self._paused = False
296
+
297
+ @property
298
+ def paused(self) -> bool:
299
+ return self._paused
300
+
301
+ def toggle_pause(self) -> None:
302
+ self._paused = not self._paused
303
+
304
+ def append_line(self, line: str) -> None:
305
+ if not self._paused:
306
+ self._buffer.append(line)
307
+ self.write(Text.from_markup(self._format_line(line)))
308
+
309
+ def get_content(self) -> str:
310
+ return "\n".join(self._buffer)
311
+
312
+ def clear(self) -> None:
313
+ self._buffer.clear()
314
+ super().clear()
315
+
316
+ @staticmethod
317
+ def _format_line(line: str) -> str:
318
+ text = escape(line.rstrip())
319
+ text = re.sub(
320
+ r"^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[+-]\d{2}:?\d{2})?)",
321
+ r"[dim]\1[/]",
322
+ text,
323
+ )
324
+ replacements = {
325
+ "ERROR": "red",
326
+ "WARN": "yellow",
327
+ "WARNING": "yellow",
328
+ "INFO": "green",
329
+ "DEBUG": "cyan",
330
+ "HTTP": "blue",
331
+ }
332
+ for word, color in replacements.items():
333
+ text = re.sub(rf"\b{word}\b", rf"[{color}]{word}[/]", text)
334
+ text = re.sub(r"\[(API|DB|Redis|HTTP|CRON)\]", r"[#22d3ee][\1][/]", text)
335
+ return text
File without changes
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Dict, Optional
5
+
6
+
7
+ def format_timestamp() -> str:
8
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
9
+
10
+
11
+ def build_report_header(
12
+ container_name: str,
13
+ image: str,
14
+ provider: str,
15
+ extra: Optional[Dict[str, str]] = None,
16
+ ) -> str:
17
+ lines = [
18
+ "=" * 60,
19
+ " Termainer Bug Report",
20
+ "=" * 60,
21
+ f" Generated: {format_timestamp()}",
22
+ f" Provider: {provider}",
23
+ f" Container: {container_name}",
24
+ f" Image: {image}",
25
+ "-" * 60,
26
+ ]
27
+ if extra:
28
+ for k, v in extra.items():
29
+ lines.append(f" {k}: {v}")
30
+ lines.append("-" * 60)
31
+ return "\n".join(lines) + "\n"
32
+
33
+
34
+ def truncate_id(full_id: str, length: int = 12) -> str:
35
+ return full_id[:length] if len(full_id) > length else full_id
36
+
37
+
38
+ def parse_cpu_percent(raw: str) -> float:
39
+ try:
40
+ return float(raw.strip().rstrip("%"))
41
+ except (ValueError, AttributeError):
42
+ return 0.0
43
+
44
+
45
+ def parse_memory_bytes(raw: str) -> float:
46
+ raw = raw.strip()
47
+ try:
48
+ if raw.endswith("GiB"):
49
+ return float(raw.replace("GiB", "").strip()) * 1024 ** 3
50
+ if raw.endswith("MiB"):
51
+ return float(raw.replace("MiB", "").strip()) * 1024 ** 2
52
+ if raw.endswith("KiB"):
53
+ return float(raw.replace("KiB", "").strip()) * 1024
54
+ return float(raw)
55
+ except (ValueError, AttributeError):
56
+ return 0.0
termainer/version.py ADDED
@@ -0,0 +1 @@
1
+ VERSION = "0.4.0"