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
termainer/ui/widgets.py
ADDED
|
@@ -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"
|