ndev-stack 0.1.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.
- ndev/__init__.py +8 -0
- ndev/__main__.py +4 -0
- ndev/cli.py +24 -0
- ndev/common/__init__.py +3 -0
- ndev/common/config.py +114 -0
- ndev/common/constants.py +51 -0
- ndev/common/github.py +13 -0
- ndev/common/logger.py +11 -0
- ndev/common/manifest.py +41 -0
- ndev/common/utils.py +96 -0
- ndev/linux/__init__.py +1 -0
- ndev/linux/chroot/manager.py +63 -0
- ndev/linux/chroot/packages.py +91 -0
- ndev/linux/chroot/shell.py +9 -0
- ndev/linux/cli.py +235 -0
- ndev/linux/commands/available.py +38 -0
- ndev/linux/commands/clean.py +25 -0
- ndev/linux/commands/ctl.py +192 -0
- ndev/linux/commands/current.py +11 -0
- ndev/linux/commands/db.py +319 -0
- ndev/linux/commands/doctor.py +56 -0
- ndev/linux/commands/grok.py +75 -0
- ndev/linux/commands/install.py +39 -0
- ndev/linux/commands/list.py +47 -0
- ndev/linux/commands/logs.py +36 -0
- ndev/linux/commands/mailpit.py +82 -0
- ndev/linux/commands/reload.py +26 -0
- ndev/linux/commands/restart.py +34 -0
- ndev/linux/commands/setup.py +113 -0
- ndev/linux/commands/start.py +34 -0
- ndev/linux/commands/status.py +81 -0
- ndev/linux/commands/stop.py +34 -0
- ndev/linux/commands/uninstall.py +69 -0
- ndev/linux/commands/update.py +57 -0
- ndev/linux/commands/upgrade.py +81 -0
- ndev/linux/commands/use.py +108 -0
- ndev/linux/commands/vhost.py +350 -0
- ndev/linux/php/builder.py +183 -0
- ndev/linux/php/downloader.py +58 -0
- ndev/linux/php/extensions.py +146 -0
- ndev/linux/php/installer.py +42 -0
- ndev/linux/php/resolver.py +59 -0
- ndev/linux/php/templates.py +128 -0
- ndev/linux/runtime/fpm.py +117 -0
- ndev/linux/runtime/mailpit.py +244 -0
- ndev/linux/runtime/pma.py +223 -0
- ndev/linux/runtime/process.py +37 -0
- ndev/linux/runtime/sockets.py +16 -0
- ndev/linux/runtime/upgrade.py +431 -0
- ndev/linux/tui.py +1423 -0
- ndev/main.py +52 -0
- ndev/tui.py +23 -0
- ndev/win/__init__.py +1 -0
- ndev/win/cli.py +1898 -0
- ndev/win/commands/__init__.py +0 -0
- ndev/win/core/__init__.py +0 -0
- ndev/win/core/db.py +265 -0
- ndev/win/core/elevate.py +94 -0
- ndev/win/core/ext.py +241 -0
- ndev/win/core/fcgi.py +216 -0
- ndev/win/core/grok.py +55 -0
- ndev/win/core/logs.py +66 -0
- ndev/win/core/mailpit.py +236 -0
- ndev/win/core/mkcert.py +65 -0
- ndev/win/core/paths.py +85 -0
- ndev/win/core/php.py +533 -0
- ndev/win/core/pma.py +190 -0
- ndev/win/core/services.py +349 -0
- ndev/win/core/setup.py +361 -0
- ndev/win/core/upgrade.py +513 -0
- ndev/win/core/vhost.py +289 -0
- ndev/win/templates/vhost.conf.tmpl +33 -0
- ndev/win/templates/vhost_ssl.conf.tmpl +43 -0
- ndev/win/tui.py +1313 -0
- ndev_stack-0.1.0.dist-info/METADATA +553 -0
- ndev_stack-0.1.0.dist-info/RECORD +79 -0
- ndev_stack-0.1.0.dist-info/WHEEL +5 -0
- ndev_stack-0.1.0.dist-info/entry_points.txt +4 -0
- ndev_stack-0.1.0.dist-info/top_level.txt +1 -0
ndev/linux/tui.py
ADDED
|
@@ -0,0 +1,1423 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Textual TUI dashboard for ndev (Linux PHP-FPM/Nginx/MariaDB stack manager).
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import datetime
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
import webbrowser
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
from textual import on, work
|
|
19
|
+
from textual.app import App, ComposeResult
|
|
20
|
+
from textual.binding import Binding
|
|
21
|
+
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
|
22
|
+
from textual.screen import ModalScreen
|
|
23
|
+
from textual.widgets import (
|
|
24
|
+
Button,
|
|
25
|
+
Checkbox,
|
|
26
|
+
DataTable,
|
|
27
|
+
Footer,
|
|
28
|
+
Header,
|
|
29
|
+
Input,
|
|
30
|
+
Label,
|
|
31
|
+
RichLog,
|
|
32
|
+
Select,
|
|
33
|
+
Static,
|
|
34
|
+
TabbedContent,
|
|
35
|
+
TabPane,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from ndev.common.constants import NDEV_DIR, PHP_DIR, CURRENT_LINK, RUN_DIR, LOGS_DIR
|
|
39
|
+
from ndev.linux.runtime.fpm import get_fpm_status, start_fpm, stop_fpm, restart_fpm
|
|
40
|
+
from ndev.linux.runtime.pma import get_pma_status, start_pma, stop_pma, restart_pma, setup_pma
|
|
41
|
+
from ndev.linux.runtime.mailpit import get_mailpit_status, start_mailpit, stop_mailpit, restart_mailpit, is_installed as is_mailpit_installed, setup_mailpit
|
|
42
|
+
from ndev.linux.runtime import upgrade as upgrade_core
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── CSS STYLESHEET ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
TUI_CSS = """
|
|
48
|
+
Screen {
|
|
49
|
+
background: $surface;
|
|
50
|
+
color: $text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ModalScreen {
|
|
54
|
+
align: center middle;
|
|
55
|
+
background: rgba(0, 0, 0, 0.75);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#modal-dialog {
|
|
59
|
+
width: 65;
|
|
60
|
+
height: auto;
|
|
61
|
+
max-height: 90%;
|
|
62
|
+
background: $surface;
|
|
63
|
+
border: thick $primary;
|
|
64
|
+
padding: 1 2;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#modal-title {
|
|
68
|
+
text-style: bold;
|
|
69
|
+
color: $accent;
|
|
70
|
+
margin-bottom: 1;
|
|
71
|
+
text-align: center;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.modal-label {
|
|
75
|
+
text-style: bold;
|
|
76
|
+
margin-top: 1;
|
|
77
|
+
margin-bottom: 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.modal-input {
|
|
81
|
+
margin-bottom: 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#modal-buttons {
|
|
85
|
+
layout: horizontal;
|
|
86
|
+
height: auto;
|
|
87
|
+
margin-top: 1;
|
|
88
|
+
align: right middle;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#modal-buttons Button {
|
|
92
|
+
margin-left: 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#app-grid {
|
|
96
|
+
layout: horizontal;
|
|
97
|
+
height: 1fr;
|
|
98
|
+
width: 1fr;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#sidebar {
|
|
102
|
+
width: 36;
|
|
103
|
+
min-width: 32;
|
|
104
|
+
max-width: 40;
|
|
105
|
+
height: 1fr;
|
|
106
|
+
background: $panel;
|
|
107
|
+
border-right: vkey $primary-background;
|
|
108
|
+
padding: 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.sidebar-section {
|
|
112
|
+
height: auto;
|
|
113
|
+
margin-bottom: 1;
|
|
114
|
+
background: $surface;
|
|
115
|
+
border: round $primary-background;
|
|
116
|
+
padding: 0 1 1 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.section-title {
|
|
120
|
+
text-style: bold;
|
|
121
|
+
color: $accent;
|
|
122
|
+
margin: 1 0;
|
|
123
|
+
text-align: center;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.sidebar-btn {
|
|
127
|
+
width: 100%;
|
|
128
|
+
height: 3;
|
|
129
|
+
margin-bottom: 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#main-content {
|
|
133
|
+
width: 1fr;
|
|
134
|
+
height: 1fr;
|
|
135
|
+
padding: 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
TabbedContent {
|
|
139
|
+
height: 1fr;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
TabPane {
|
|
143
|
+
padding: 1;
|
|
144
|
+
height: 1fr;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
DataTable {
|
|
148
|
+
height: 1fr;
|
|
149
|
+
border: round $primary;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.tab-action-bar {
|
|
153
|
+
layout: horizontal;
|
|
154
|
+
height: auto;
|
|
155
|
+
margin-top: 1;
|
|
156
|
+
align: right middle;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.tab-action-bar Button {
|
|
160
|
+
margin-left: 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#console-log {
|
|
164
|
+
height: 1fr;
|
|
165
|
+
border: round $accent;
|
|
166
|
+
background: $background;
|
|
167
|
+
color: $text;
|
|
168
|
+
padding: 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#status-summary-box {
|
|
172
|
+
color: $text-muted;
|
|
173
|
+
margin-top: 1;
|
|
174
|
+
padding: 1;
|
|
175
|
+
border: dashed $primary-background;
|
|
176
|
+
}
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── MODAL DIALOG SCREENS ──────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
class CreateVhostModal(ModalScreen[Optional[dict]]):
|
|
183
|
+
"""Modal dialog to create a new virtual host with local SSL on Linux."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, installed_phps: list[str], default_php: Optional[str] = None) -> None:
|
|
186
|
+
super().__init__()
|
|
187
|
+
self.installed_phps = installed_phps
|
|
188
|
+
self.default_php = default_php or (installed_phps[0] if installed_phps else "8.4")
|
|
189
|
+
|
|
190
|
+
def compose(self) -> ComposeResult:
|
|
191
|
+
php_options = [(v, v) for v in self.installed_phps] if self.installed_phps else [(self.default_php, self.default_php)]
|
|
192
|
+
initial_php = self.default_php if self.default_php in [o[1] for o in php_options] else (php_options[0][1] if php_options else "8.4")
|
|
193
|
+
|
|
194
|
+
with Vertical(id="modal-dialog"):
|
|
195
|
+
yield Label("🌐 Create New Virtual Host", id="modal-title")
|
|
196
|
+
yield Label("Domain Name (e.g. app.test):", classes="modal-label")
|
|
197
|
+
yield Input(placeholder="app.test", id="input-vhost-domain", classes="modal-input")
|
|
198
|
+
yield Label("Document Root Directory:", classes="modal-label")
|
|
199
|
+
yield Input(placeholder="/var/www/app (or public folder)", id="input-vhost-root", classes="modal-input")
|
|
200
|
+
yield Label("PHP Version:", classes="modal-label")
|
|
201
|
+
yield Select(php_options, value=initial_php, id="select-vhost-php", classes="modal-input")
|
|
202
|
+
yield Checkbox("Enable Local SSL (HTTPS with mkcert)", value=True, id="chk-vhost-ssl")
|
|
203
|
+
with Horizontal(id="modal-buttons"):
|
|
204
|
+
yield Button("Create VHost", variant="success", id="btn-modal-create")
|
|
205
|
+
yield Button("Cancel", variant="default", id="btn-modal-cancel")
|
|
206
|
+
|
|
207
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
208
|
+
if event.button.id == "btn-modal-create":
|
|
209
|
+
domain = self.query_one("#input-vhost-domain", Input).value.strip()
|
|
210
|
+
root = self.query_one("#input-vhost-root", Input).value.strip()
|
|
211
|
+
php_val = self.query_one("#select-vhost-php", Select).value
|
|
212
|
+
php_ver = str(php_val) if (php_val is not None and php_val != Select.BLANK) else self.default_php
|
|
213
|
+
ssl = self.query_one("#chk-vhost-ssl", Checkbox).value
|
|
214
|
+
|
|
215
|
+
if not domain:
|
|
216
|
+
self.notify("Domain name is required.", severity="error")
|
|
217
|
+
return
|
|
218
|
+
if not root:
|
|
219
|
+
self.notify("Document root directory is required.", severity="error")
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
self.dismiss({
|
|
223
|
+
"domain": domain,
|
|
224
|
+
"root": root,
|
|
225
|
+
"php": php_ver,
|
|
226
|
+
"ssl": bool(ssl),
|
|
227
|
+
})
|
|
228
|
+
else:
|
|
229
|
+
self.dismiss(None)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class ConfirmActionModal(ModalScreen[bool]):
|
|
233
|
+
"""Generic confirmation dialog for deletions and uninstalls on Linux."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, title: str, message: str, confirm_label: str = "Confirm", variant: str = "error") -> None:
|
|
236
|
+
super().__init__()
|
|
237
|
+
self.title_text = title
|
|
238
|
+
self.message_text = message
|
|
239
|
+
self.confirm_label = confirm_label
|
|
240
|
+
self.btn_variant = variant
|
|
241
|
+
|
|
242
|
+
def compose(self) -> ComposeResult:
|
|
243
|
+
with Vertical(id="modal-dialog"):
|
|
244
|
+
yield Label(self.title_text, id="modal-title")
|
|
245
|
+
yield Label(self.message_text, classes="modal-label")
|
|
246
|
+
with Horizontal(id="modal-buttons"):
|
|
247
|
+
yield Button(self.confirm_label, variant=self.btn_variant, id="btn-modal-confirm")
|
|
248
|
+
yield Button("Cancel", variant="default", id="btn-modal-cancel")
|
|
249
|
+
|
|
250
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
251
|
+
if event.button.id == "btn-modal-confirm":
|
|
252
|
+
self.dismiss(True)
|
|
253
|
+
else:
|
|
254
|
+
self.dismiss(False)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class InstallPhpModal(ModalScreen[Optional[dict]]):
|
|
258
|
+
"""Modal dialog to select and install a PHP runtime on Linux."""
|
|
259
|
+
|
|
260
|
+
def __init__(self, popular_versions: Optional[list[str]] = None) -> None:
|
|
261
|
+
super().__init__()
|
|
262
|
+
self.popular_versions = popular_versions or ["8.4.25", "8.3.17", "8.2.27", "8.1.31", "7.4.33"]
|
|
263
|
+
|
|
264
|
+
def compose(self) -> ComposeResult:
|
|
265
|
+
opts = [(v, v) for v in self.popular_versions]
|
|
266
|
+
default_v = opts[0][1] if opts else "8.4.25"
|
|
267
|
+
|
|
268
|
+
with Vertical(id="modal-dialog"):
|
|
269
|
+
yield Label("🐘 Install PHP Runtime", id="modal-title")
|
|
270
|
+
yield Label("Select PHP Release:", classes="modal-label")
|
|
271
|
+
yield Select(opts, value=default_v, id="select-php-ver", classes="modal-input")
|
|
272
|
+
yield Label("Or specify Custom Version (e.g. 8.4.25):", classes="modal-label")
|
|
273
|
+
yield Input(placeholder="Leave blank to use selected release above", id="input-custom-ver", classes="modal-input")
|
|
274
|
+
with Horizontal(id="modal-buttons"):
|
|
275
|
+
yield Button("Install PHP", variant="success", id="btn-modal-install")
|
|
276
|
+
yield Button("Cancel", variant="default", id="btn-modal-cancel")
|
|
277
|
+
|
|
278
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
279
|
+
if event.button.id == "btn-modal-install":
|
|
280
|
+
custom = self.query_one("#input-custom-ver", Input).value.strip()
|
|
281
|
+
sel_val = self.query_one("#select-php-ver", Select).value
|
|
282
|
+
selected = str(sel_val) if (sel_val is not None and sel_val != Select.BLANK) else "8.4.25"
|
|
283
|
+
version = custom if custom else selected
|
|
284
|
+
|
|
285
|
+
self.dismiss({
|
|
286
|
+
"version": version,
|
|
287
|
+
})
|
|
288
|
+
else:
|
|
289
|
+
self.dismiss(None)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── MAIN APPLICATION ──────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
class NdevDashboard(App):
|
|
295
|
+
"""Modern asynchronous TUI dashboard for ndev Linux."""
|
|
296
|
+
|
|
297
|
+
TITLE = "ndev"
|
|
298
|
+
SUB_TITLE = "Local Web Stack Dashboard (Linux)"
|
|
299
|
+
CSS = TUI_CSS
|
|
300
|
+
|
|
301
|
+
BINDINGS = [
|
|
302
|
+
Binding("q", "quit", "Quit", priority=True),
|
|
303
|
+
Binding("r", "refresh_all", "Refresh", priority=True),
|
|
304
|
+
Binding("s", "start_all", "Start All"),
|
|
305
|
+
Binding("x", "stop_all", "Stop All"),
|
|
306
|
+
Binding("t", "restart_all", "Restart All"),
|
|
307
|
+
Binding("l", "reload_nginx", "Reload Nginx"),
|
|
308
|
+
Binding("u", "check_upgrades", "Check Upgrades"),
|
|
309
|
+
Binding("U", "upgrade_stack", "Upgrade Stack"),
|
|
310
|
+
Binding("v", "create_vhost", "Create VHost"),
|
|
311
|
+
Binding("i", "install_php", "Install PHP"),
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
def __init__(self) -> None:
|
|
315
|
+
super().__init__()
|
|
316
|
+
self._is_refreshing = False
|
|
317
|
+
self._updating_selects = False
|
|
318
|
+
self._available_logs: Dict[str, Path] = {}
|
|
319
|
+
self._selected_service_key: Optional[str] = None
|
|
320
|
+
|
|
321
|
+
def compose(self) -> ComposeResult:
|
|
322
|
+
yield Header(show_clock=True)
|
|
323
|
+
|
|
324
|
+
with Horizontal(id="app-grid"):
|
|
325
|
+
# ── SIDEBAR ──
|
|
326
|
+
with VerticalScroll(id="sidebar"):
|
|
327
|
+
with Vertical(classes="sidebar-section"):
|
|
328
|
+
yield Label("⚡ Quick Actions", classes="section-title")
|
|
329
|
+
yield Button("▶ Start All", id="btn-start-all", variant="success", classes="sidebar-btn")
|
|
330
|
+
yield Button("⏹ Stop All", id="btn-stop-all", variant="error", classes="sidebar-btn")
|
|
331
|
+
yield Button("🔄 Restart All", id="btn-restart-all", variant="warning", classes="sidebar-btn")
|
|
332
|
+
yield Button("⚡ Reload Nginx", id="btn-reload-nginx", variant="primary", classes="sidebar-btn")
|
|
333
|
+
yield Button("🌐 New Virtual Host", id="btn-sidebar-create-vhost", variant="success", classes="sidebar-btn")
|
|
334
|
+
yield Button("🐘 Install PHP", id="btn-sidebar-install-php", variant="success", classes="sidebar-btn")
|
|
335
|
+
yield Button("🔍 Check Upgrades", id="btn-check-upgrades", variant="default", classes="sidebar-btn")
|
|
336
|
+
yield Button("🚀 Upgrade Stack", id="btn-upgrade-stack", variant="primary", classes="sidebar-btn")
|
|
337
|
+
|
|
338
|
+
with Vertical(classes="sidebar-section"):
|
|
339
|
+
yield Label("🐘 Active PHP CLI", classes="section-title")
|
|
340
|
+
yield Select([], id="select-php-version", prompt="Select PHP Version")
|
|
341
|
+
|
|
342
|
+
with Vertical(classes="sidebar-section"):
|
|
343
|
+
yield Label("📜 Service Logs", classes="section-title")
|
|
344
|
+
yield Select([], id="select-log-file", prompt="Select Log File")
|
|
345
|
+
yield Button("Tail Selected Log", id="btn-tail-log", variant="default", classes="sidebar-btn")
|
|
346
|
+
|
|
347
|
+
with Vertical(id="status-summary-box"):
|
|
348
|
+
yield Label("[bold]🌐 Ports Reference[/bold]")
|
|
349
|
+
yield Label("• Nginx: 80 / 443 (SSL)")
|
|
350
|
+
yield Label("• MariaDB: 3306")
|
|
351
|
+
yield Label("• PMA (phpMyAdmin): 8080")
|
|
352
|
+
yield Label("• Mailpit Web: 8025")
|
|
353
|
+
yield Label("• Mailpit SMTP: 1025")
|
|
354
|
+
|
|
355
|
+
# ── MAIN CONTENT (TABS) ──
|
|
356
|
+
with Container(id="main-content"):
|
|
357
|
+
with TabbedContent(initial="tab-services"):
|
|
358
|
+
with TabPane("⚡ Services & Pools", id="tab-services"):
|
|
359
|
+
yield DataTable(id="table-services", cursor_type="row", zebra_stripes=True)
|
|
360
|
+
with Horizontal(classes="tab-action-bar"):
|
|
361
|
+
yield Button("Start Selected", id="btn-svc-start", variant="success")
|
|
362
|
+
yield Button("Stop Selected", id="btn-svc-stop", variant="error")
|
|
363
|
+
yield Button("Restart Selected", id="btn-svc-restart", variant="warning")
|
|
364
|
+
yield Button("Install Selected", id="btn-svc-install", variant="success")
|
|
365
|
+
yield Button("Open Web UI", id="btn-svc-open", variant="primary")
|
|
366
|
+
yield Button("Check Upgrades", id="btn-svc-check-upgrades", variant="default")
|
|
367
|
+
|
|
368
|
+
with TabPane("🌐 Virtual Hosts", id="tab-vhosts"):
|
|
369
|
+
yield DataTable(id="table-vhosts", cursor_type="row", zebra_stripes=True)
|
|
370
|
+
with Horizontal(classes="tab-action-bar"):
|
|
371
|
+
yield Button("+ Create VHost", id="btn-vhost-create", variant="success")
|
|
372
|
+
yield Button("✖ Delete Selected", id="btn-vhost-delete", variant="error")
|
|
373
|
+
yield Button("🌐 Open in Browser", id="btn-vhost-open", variant="primary")
|
|
374
|
+
|
|
375
|
+
with TabPane("🐘 PHP Runtimes", id="tab-php"):
|
|
376
|
+
yield DataTable(id="table-php", cursor_type="row", zebra_stripes=True)
|
|
377
|
+
with Horizontal(classes="tab-action-bar"):
|
|
378
|
+
yield Button("+ Install PHP", id="btn-php-install", variant="success")
|
|
379
|
+
yield Button("✖ Uninstall Selected", id="btn-php-uninstall", variant="error")
|
|
380
|
+
yield Button("⭐ Set as Active CLI", id="btn-php-set-active", variant="primary")
|
|
381
|
+
|
|
382
|
+
with TabPane("📋 Live Console / Logs", id="tab-logs"):
|
|
383
|
+
yield RichLog(id="console-log", highlight=True, markup=True)
|
|
384
|
+
with Horizontal(classes="tab-action-bar"):
|
|
385
|
+
yield Button("Clear Console", id="btn-clear-console", variant="default")
|
|
386
|
+
|
|
387
|
+
yield Footer()
|
|
388
|
+
|
|
389
|
+
# ── LIFECYCLE HOOKS ───────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
async def on_mount(self) -> None:
|
|
392
|
+
"""Initialize data tables, populate dropdowns, and start background polling."""
|
|
393
|
+
self._init_tables()
|
|
394
|
+
self.log_message("[bold green]ndev Linux TUI dashboard loaded.[/bold green]")
|
|
395
|
+
|
|
396
|
+
# Initial data load
|
|
397
|
+
self._refresh_all_data(full_rebuild=True)
|
|
398
|
+
|
|
399
|
+
# Polling timer every 3.5 seconds
|
|
400
|
+
self.set_interval(3.5, self._on_poll_timer)
|
|
401
|
+
|
|
402
|
+
def _init_tables(self) -> None:
|
|
403
|
+
"""Configure columns for all DataTables."""
|
|
404
|
+
svc_table = self.query_one("#table-services", DataTable)
|
|
405
|
+
svc_table.add_columns("Status", "Service", "Type", "PID", "Socket / Port / Details")
|
|
406
|
+
|
|
407
|
+
vhost_table = self.query_one("#table-vhosts", DataTable)
|
|
408
|
+
vhost_table.add_columns("Domain", "URL", "PHP Target", "SSL", "Document Root")
|
|
409
|
+
|
|
410
|
+
php_table = self.query_one("#table-php", DataTable)
|
|
411
|
+
php_table.add_columns("Version", "CLI Active", "FPM Status", "Socket Path", "Installation Directory")
|
|
412
|
+
|
|
413
|
+
def _on_poll_timer(self) -> None:
|
|
414
|
+
"""Periodic background status update."""
|
|
415
|
+
if not self._is_refreshing:
|
|
416
|
+
self._refresh_all_data(full_rebuild=False)
|
|
417
|
+
|
|
418
|
+
def log_message(self, message: str) -> None:
|
|
419
|
+
"""Append formatted timestamped text to the RichLog console widget."""
|
|
420
|
+
def _write() -> None:
|
|
421
|
+
now = datetime.datetime.now().strftime("%H:%M:%S")
|
|
422
|
+
try:
|
|
423
|
+
log_widget = self.query_one("#console-log", RichLog)
|
|
424
|
+
log_widget.write(f"[dim]{now}[/dim] {message}")
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
if not self.is_mounted:
|
|
429
|
+
return
|
|
430
|
+
if hasattr(self, "_thread_id") and threading.get_ident() == self._thread_id:
|
|
431
|
+
_write()
|
|
432
|
+
else:
|
|
433
|
+
try:
|
|
434
|
+
self.call_from_thread(_write)
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
# ── ASYNC DATA FETCHERS ───────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
async def _fetch_services_data(self) -> List[Dict[str, Any]]:
|
|
441
|
+
"""Collect states for system services and ndev runtimes."""
|
|
442
|
+
def _get() -> List[Dict[str, Any]]:
|
|
443
|
+
results = []
|
|
444
|
+
|
|
445
|
+
# 1. Nginx
|
|
446
|
+
ng_installed = bool(shutil.which("nginx"))
|
|
447
|
+
ng_running = False
|
|
448
|
+
if ng_installed:
|
|
449
|
+
if shutil.which("systemctl"):
|
|
450
|
+
res = subprocess.run(["systemctl", "is-active", "--quiet", "nginx"])
|
|
451
|
+
ng_running = (res.returncode == 0)
|
|
452
|
+
else:
|
|
453
|
+
ng_running = Path("/var/run/nginx.pid").exists()
|
|
454
|
+
|
|
455
|
+
results.append({
|
|
456
|
+
"key": "nginx",
|
|
457
|
+
"name": "Nginx",
|
|
458
|
+
"type": "Web Server",
|
|
459
|
+
"installed": ng_installed,
|
|
460
|
+
"running": ng_running,
|
|
461
|
+
"pid": "Active" if ng_running else "-",
|
|
462
|
+
"details": "Ports: 80, 443 | Config: /etc/nginx/",
|
|
463
|
+
"url": "http://127.0.0.1",
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
# 2. MariaDB / MySQL
|
|
467
|
+
db_installed = bool(shutil.which("mariadb") or shutil.which("mysql"))
|
|
468
|
+
db_running = False
|
|
469
|
+
if db_installed:
|
|
470
|
+
if shutil.which("systemctl"):
|
|
471
|
+
res = subprocess.run(["systemctl", "is-active", "--quiet", "mariadb"])
|
|
472
|
+
if res.returncode != 0:
|
|
473
|
+
res = subprocess.run(["systemctl", "is-active", "--quiet", "mysql"])
|
|
474
|
+
db_running = (res.returncode == 0)
|
|
475
|
+
|
|
476
|
+
results.append({
|
|
477
|
+
"key": "mariadb",
|
|
478
|
+
"name": "MariaDB / MySQL",
|
|
479
|
+
"type": "Database",
|
|
480
|
+
"installed": db_installed,
|
|
481
|
+
"running": db_running,
|
|
482
|
+
"pid": "Active" if db_running else "-",
|
|
483
|
+
"details": "Port: 3306 (user: root)",
|
|
484
|
+
"url": None,
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
# 3. phpMyAdmin
|
|
488
|
+
pma_st = get_pma_status()
|
|
489
|
+
pma_url = pma_st.get("url") or f"http://127.0.0.1:{pma_st.get('port', 8080)}"
|
|
490
|
+
results.append({
|
|
491
|
+
"key": "pma",
|
|
492
|
+
"name": "phpMyAdmin",
|
|
493
|
+
"type": "Admin Tool",
|
|
494
|
+
"installed": pma_st.get("installed", False),
|
|
495
|
+
"running": pma_st.get("running", False),
|
|
496
|
+
"pid": str(pma_st.get("pid") or "-"),
|
|
497
|
+
"details": f"{pma_url}" if pma_st.get("running") else "Port: 8080",
|
|
498
|
+
"url": pma_url if pma_st.get("running") else None,
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
# 4. Mailpit
|
|
502
|
+
mp_st = get_mailpit_status()
|
|
503
|
+
mp_url = mp_st.get("url") or f"http://127.0.0.1:{mp_st.get('web_port', 8025)}"
|
|
504
|
+
results.append({
|
|
505
|
+
"key": "mailpit",
|
|
506
|
+
"name": "Mailpit",
|
|
507
|
+
"type": "Email Sandbox",
|
|
508
|
+
"installed": mp_st.get("installed", False),
|
|
509
|
+
"running": mp_st.get("running", False),
|
|
510
|
+
"pid": str(mp_st.get("pid") or "-"),
|
|
511
|
+
"details": f"Web: {mp_url} | SMTP: 127.0.0.1:{mp_st.get('smtp_port', 1025)}" if mp_st.get("running") else "Web: 8025 | SMTP: 1025",
|
|
512
|
+
"url": mp_url if mp_st.get("running") else None,
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
# 5. PHP-FPM Pools
|
|
516
|
+
installed_phps = []
|
|
517
|
+
if PHP_DIR.exists():
|
|
518
|
+
for d in PHP_DIR.iterdir():
|
|
519
|
+
if d.is_dir():
|
|
520
|
+
installed_phps.append(d.name)
|
|
521
|
+
installed_phps.sort()
|
|
522
|
+
|
|
523
|
+
curr_php = CURRENT_LINK.resolve().name if (CURRENT_LINK.exists() or CURRENT_LINK.is_symlink()) else None
|
|
524
|
+
|
|
525
|
+
for v in installed_phps:
|
|
526
|
+
fpm_st = get_fpm_status(v)
|
|
527
|
+
is_running = fpm_st.get("running", False)
|
|
528
|
+
is_active = (v == curr_php)
|
|
529
|
+
cli_tag = " [active CLI]" if is_active else ""
|
|
530
|
+
sock_str = str(fpm_st.get("socket", "-"))
|
|
531
|
+
results.append({
|
|
532
|
+
"key": f"php:{v}",
|
|
533
|
+
"name": f"PHP-FPM {v}{cli_tag}",
|
|
534
|
+
"type": "PHP Daemon",
|
|
535
|
+
"installed": True,
|
|
536
|
+
"running": is_running,
|
|
537
|
+
"pid": str(fpm_st.get("pid") or "-"),
|
|
538
|
+
"details": f"Socket: {sock_str}",
|
|
539
|
+
"url": None,
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
return results
|
|
543
|
+
|
|
544
|
+
return await asyncio.to_thread(_get)
|
|
545
|
+
|
|
546
|
+
async def _fetch_vhosts_data(self) -> List[Dict[str, Any]]:
|
|
547
|
+
"""Parse Nginx virtual hosts configurations."""
|
|
548
|
+
def _get() -> List[Dict[str, Any]]:
|
|
549
|
+
vhosts = []
|
|
550
|
+
search_dirs = [
|
|
551
|
+
Path("/etc/nginx/sites-available"),
|
|
552
|
+
NDEV_DIR / "vhosts",
|
|
553
|
+
]
|
|
554
|
+
for sdir in search_dirs:
|
|
555
|
+
if not sdir.exists():
|
|
556
|
+
continue
|
|
557
|
+
for conf in sdir.glob("*.conf"):
|
|
558
|
+
try:
|
|
559
|
+
content = conf.read_text(errors="ignore")
|
|
560
|
+
# server_name
|
|
561
|
+
sn_match = re.search(r"server_name\s+([^;]+);", content)
|
|
562
|
+
domain = sn_match.group(1).split()[0] if sn_match else conf.stem
|
|
563
|
+
|
|
564
|
+
# root
|
|
565
|
+
root_match = re.search(r"root\s+([^;]+);", content)
|
|
566
|
+
docroot = root_match.group(1) if root_match else "-"
|
|
567
|
+
|
|
568
|
+
# fastcgi_pass
|
|
569
|
+
fcgi_match = re.search(r"fastcgi_pass\s+([^;]+);", content)
|
|
570
|
+
php_target = fcgi_match.group(1) if fcgi_match else "Default"
|
|
571
|
+
|
|
572
|
+
# ssl
|
|
573
|
+
has_ssl = "ssl_certificate" in content or "listen 443" in content or "listen [::]:443" in content
|
|
574
|
+
|
|
575
|
+
vhosts.append({
|
|
576
|
+
"domain": domain,
|
|
577
|
+
"root": docroot,
|
|
578
|
+
"php": php_target,
|
|
579
|
+
"ssl": has_ssl,
|
|
580
|
+
"conf": str(conf),
|
|
581
|
+
})
|
|
582
|
+
except Exception:
|
|
583
|
+
pass
|
|
584
|
+
return vhosts
|
|
585
|
+
|
|
586
|
+
return await asyncio.to_thread(_get)
|
|
587
|
+
|
|
588
|
+
async def _fetch_php_data(self) -> Tuple[List[str], Optional[str]]:
|
|
589
|
+
def _get() -> Tuple[List[str], Optional[str]]:
|
|
590
|
+
installed = []
|
|
591
|
+
if PHP_DIR.exists():
|
|
592
|
+
for d in PHP_DIR.iterdir():
|
|
593
|
+
if d.is_dir():
|
|
594
|
+
installed.append(d.name)
|
|
595
|
+
installed.sort()
|
|
596
|
+
curr = CURRENT_LINK.resolve().name if (CURRENT_LINK.exists() or CURRENT_LINK.is_symlink()) else None
|
|
597
|
+
return installed, curr
|
|
598
|
+
return await asyncio.to_thread(_get)
|
|
599
|
+
|
|
600
|
+
async def _fetch_logs_dict(self) -> Dict[str, Path]:
|
|
601
|
+
def _get() -> Dict[str, Path]:
|
|
602
|
+
logs_map = {}
|
|
603
|
+
# Nginx logs
|
|
604
|
+
for p in Path("/var/log/nginx").glob("*.log"):
|
|
605
|
+
logs_map[f"nginx:{p.stem}"] = p
|
|
606
|
+
# ndev logs
|
|
607
|
+
if LOGS_DIR.exists():
|
|
608
|
+
for p in LOGS_DIR.glob("*.log"):
|
|
609
|
+
logs_map[f"ndev:{p.stem}"] = p
|
|
610
|
+
# PHP logs
|
|
611
|
+
if PHP_DIR.exists():
|
|
612
|
+
for v in PHP_DIR.iterdir():
|
|
613
|
+
if v.is_dir():
|
|
614
|
+
for cand in [v / "var" / "log" / "php-fpm.log", v / "php_error.log", v / "error.log"]:
|
|
615
|
+
if cand.exists():
|
|
616
|
+
logs_map[f"php:{v.name}"] = cand
|
|
617
|
+
return logs_map
|
|
618
|
+
return await asyncio.to_thread(_get)
|
|
619
|
+
|
|
620
|
+
# ── DATA TABLE & DROPDOWN POPULATION ──────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
@work
|
|
623
|
+
async def _refresh_all_data(self, full_rebuild: bool = False) -> None:
|
|
624
|
+
"""Main non-blocking data synchronization routine with selection preservation."""
|
|
625
|
+
if self._is_refreshing:
|
|
626
|
+
return
|
|
627
|
+
self._is_refreshing = True
|
|
628
|
+
try:
|
|
629
|
+
services_data, vhosts_data, (installed_phps, curr_php), logs_dict = await asyncio.gather(
|
|
630
|
+
self._fetch_services_data(),
|
|
631
|
+
self._fetch_vhosts_data(),
|
|
632
|
+
self._fetch_php_data(),
|
|
633
|
+
self._fetch_logs_dict(),
|
|
634
|
+
)
|
|
635
|
+
self._available_logs = logs_dict
|
|
636
|
+
|
|
637
|
+
# 1. Update Services Table (Preserving Cursor)
|
|
638
|
+
svc_table = self.query_one("#table-services", DataTable)
|
|
639
|
+
saved_svc_row = svc_table.cursor_row
|
|
640
|
+
svc_table.clear()
|
|
641
|
+
for svc in services_data:
|
|
642
|
+
if not svc["installed"]:
|
|
643
|
+
status_text = Text("NOT INSTALLED", style="bold yellow")
|
|
644
|
+
elif svc["running"]:
|
|
645
|
+
status_text = Text("RUNNING", style="bold green")
|
|
646
|
+
else:
|
|
647
|
+
status_text = Text("STOPPED", style="bold red")
|
|
648
|
+
|
|
649
|
+
svc_table.add_row(
|
|
650
|
+
status_text,
|
|
651
|
+
svc["name"],
|
|
652
|
+
svc["type"],
|
|
653
|
+
str(svc["pid"]),
|
|
654
|
+
svc["details"],
|
|
655
|
+
key=svc["key"],
|
|
656
|
+
)
|
|
657
|
+
if saved_svc_row is not None and saved_svc_row < svc_table.row_count:
|
|
658
|
+
svc_table.move_cursor(row=saved_svc_row)
|
|
659
|
+
|
|
660
|
+
# 2. Update Virtual Hosts Table (Preserving Cursor)
|
|
661
|
+
vhost_table = self.query_one("#table-vhosts", DataTable)
|
|
662
|
+
saved_vh_row = vhost_table.cursor_row
|
|
663
|
+
vhost_table.clear()
|
|
664
|
+
for vh in vhosts_data:
|
|
665
|
+
proto = "https" if vh.get("ssl") else "http"
|
|
666
|
+
url = f"{proto}://{vh['domain']}"
|
|
667
|
+
ssl_badge = Text("SSL Enabled", style="bold green") if vh.get("ssl") else Text("Plain HTTP", style="dim")
|
|
668
|
+
vhost_table.add_row(
|
|
669
|
+
vh["domain"],
|
|
670
|
+
url,
|
|
671
|
+
str(vh.get("php", "-")),
|
|
672
|
+
ssl_badge,
|
|
673
|
+
str(vh.get("root", "-")),
|
|
674
|
+
key=vh["domain"],
|
|
675
|
+
)
|
|
676
|
+
if saved_vh_row is not None and saved_vh_row < vhost_table.row_count:
|
|
677
|
+
vhost_table.move_cursor(row=saved_vh_row)
|
|
678
|
+
|
|
679
|
+
# 3. Update PHP Table (Preserving Cursor)
|
|
680
|
+
php_table = self.query_one("#table-php", DataTable)
|
|
681
|
+
saved_php_row = php_table.cursor_row
|
|
682
|
+
php_table.clear()
|
|
683
|
+
for v in installed_phps:
|
|
684
|
+
is_active = (v == curr_php)
|
|
685
|
+
cli_badge = Text("ACTIVE", style="bold green") if is_active else Text("Inactive", style="dim")
|
|
686
|
+
fpm_st = get_fpm_status(v)
|
|
687
|
+
fpm_badge = Text("RUNNING", style="bold green") if fpm_st.get("running") else Text("STOPPED", style="bold red")
|
|
688
|
+
php_table.add_row(
|
|
689
|
+
v,
|
|
690
|
+
cli_badge,
|
|
691
|
+
fpm_badge,
|
|
692
|
+
str(fpm_st.get("socket", "-")),
|
|
693
|
+
str(PHP_DIR / v),
|
|
694
|
+
key=v,
|
|
695
|
+
)
|
|
696
|
+
if saved_php_row is not None and saved_php_row < php_table.row_count:
|
|
697
|
+
php_table.move_cursor(row=saved_php_row)
|
|
698
|
+
|
|
699
|
+
# 4. Update Dropdowns if full rebuild requested
|
|
700
|
+
if full_rebuild:
|
|
701
|
+
self._updating_selects = True
|
|
702
|
+
try:
|
|
703
|
+
# PHP Version Selector
|
|
704
|
+
php_select = self.query_one("#select-php-version", Select)
|
|
705
|
+
php_options = [(f"PHP {v}" + (" (Active)" if v == curr_php else ""), v) for v in installed_phps]
|
|
706
|
+
php_select.set_options(php_options)
|
|
707
|
+
if curr_php and curr_php in installed_phps:
|
|
708
|
+
php_select.value = curr_php
|
|
709
|
+
|
|
710
|
+
# Log Selector
|
|
711
|
+
self._available_logs = logs_dict
|
|
712
|
+
log_select = self.query_one("#select-log-file", Select)
|
|
713
|
+
log_options = [(name, name) for name in sorted(logs_dict.keys())]
|
|
714
|
+
log_select.set_options(log_options)
|
|
715
|
+
finally:
|
|
716
|
+
self._updating_selects = False
|
|
717
|
+
|
|
718
|
+
except Exception as e:
|
|
719
|
+
self.log_message(f"[bold red]Error updating dashboard: {e}[/bold red]")
|
|
720
|
+
finally:
|
|
721
|
+
self._is_refreshing = False
|
|
722
|
+
|
|
723
|
+
# ── ACTION WORKERS ────────────────────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
@work(exclusive=True)
|
|
726
|
+
async def action_start_all(self) -> None:
|
|
727
|
+
"""Start all services and FastCGI pools."""
|
|
728
|
+
self.log_message("[bold blue]Starting all services...[/bold blue]")
|
|
729
|
+
self.notify("Starting all web services...", severity="information")
|
|
730
|
+
|
|
731
|
+
def _do() -> None:
|
|
732
|
+
# 1. Nginx
|
|
733
|
+
if shutil.which("systemctl"):
|
|
734
|
+
subprocess.run(["sudo", "systemctl", "start", "nginx"])
|
|
735
|
+
# 2. MariaDB
|
|
736
|
+
if shutil.which("systemctl"):
|
|
737
|
+
subprocess.run(["sudo", "systemctl", "start", "mariadb"])
|
|
738
|
+
# 3. phpMyAdmin
|
|
739
|
+
try:
|
|
740
|
+
start_pma()
|
|
741
|
+
except Exception as e:
|
|
742
|
+
self.log_message(f"[yellow]phpMyAdmin notice: {e}[/yellow]")
|
|
743
|
+
# 4. Mailpit
|
|
744
|
+
try:
|
|
745
|
+
if is_mailpit_installed() and not get_mailpit_status()["running"]:
|
|
746
|
+
start_mailpit()
|
|
747
|
+
except Exception as e:
|
|
748
|
+
self.log_message(f"[yellow]Mailpit notice: {e}[/yellow]")
|
|
749
|
+
# 5. PHP-FPM pools
|
|
750
|
+
if PHP_DIR.exists():
|
|
751
|
+
for d in PHP_DIR.iterdir():
|
|
752
|
+
if d.is_dir():
|
|
753
|
+
try:
|
|
754
|
+
start_fpm(d.name)
|
|
755
|
+
except Exception as e:
|
|
756
|
+
self.log_message(f"[yellow]PHP {d.name} pool notice: {e}[/yellow]")
|
|
757
|
+
|
|
758
|
+
await asyncio.to_thread(_do)
|
|
759
|
+
self.log_message("[bold green]✓ All services started.[/bold green]")
|
|
760
|
+
self.notify("All services started.", severity="information")
|
|
761
|
+
self._refresh_all_data(full_rebuild=False)
|
|
762
|
+
|
|
763
|
+
@work(exclusive=True)
|
|
764
|
+
async def action_stop_all(self) -> None:
|
|
765
|
+
"""Stop all running services and FastCGI pools."""
|
|
766
|
+
self.log_message("[bold blue]Stopping all services...[/bold blue]")
|
|
767
|
+
self.notify("Stopping all web services...", severity="information")
|
|
768
|
+
|
|
769
|
+
def _do() -> None:
|
|
770
|
+
if shutil.which("systemctl"):
|
|
771
|
+
subprocess.run(["sudo", "systemctl", "stop", "nginx"])
|
|
772
|
+
subprocess.run(["sudo", "systemctl", "stop", "mariadb"])
|
|
773
|
+
stop_pma()
|
|
774
|
+
stop_mailpit()
|
|
775
|
+
if PHP_DIR.exists():
|
|
776
|
+
for d in PHP_DIR.iterdir():
|
|
777
|
+
if d.is_dir():
|
|
778
|
+
stop_fpm(d.name)
|
|
779
|
+
|
|
780
|
+
await asyncio.to_thread(_do)
|
|
781
|
+
self.log_message("[bold green]✓ All services stopped.[/bold green]")
|
|
782
|
+
self.notify("All services stopped.", severity="information")
|
|
783
|
+
self._refresh_all_data(full_rebuild=False)
|
|
784
|
+
|
|
785
|
+
@work(exclusive=True)
|
|
786
|
+
async def action_restart_all(self) -> None:
|
|
787
|
+
"""Restart all services."""
|
|
788
|
+
self.log_message("[bold blue]Restarting all services...[/bold blue]")
|
|
789
|
+
self.notify("Restarting all web services...", severity="information")
|
|
790
|
+
|
|
791
|
+
def _do() -> None:
|
|
792
|
+
if shutil.which("systemctl"):
|
|
793
|
+
subprocess.run(["sudo", "systemctl", "restart", "nginx"])
|
|
794
|
+
subprocess.run(["sudo", "systemctl", "restart", "mariadb"])
|
|
795
|
+
restart_pma()
|
|
796
|
+
if is_mailpit_installed():
|
|
797
|
+
restart_mailpit()
|
|
798
|
+
if PHP_DIR.exists():
|
|
799
|
+
for d in PHP_DIR.iterdir():
|
|
800
|
+
if d.is_dir():
|
|
801
|
+
restart_fpm(d.name)
|
|
802
|
+
|
|
803
|
+
await asyncio.to_thread(_do)
|
|
804
|
+
self.log_message("[bold green]✓ All services restarted.[/bold green]")
|
|
805
|
+
self.notify("All services restarted.", severity="information")
|
|
806
|
+
self._refresh_all_data(full_rebuild=False)
|
|
807
|
+
|
|
808
|
+
@work(exclusive=True)
|
|
809
|
+
async def action_reload_nginx(self) -> None:
|
|
810
|
+
"""Reload Nginx configuration."""
|
|
811
|
+
self.log_message("[bold blue]Reloading Nginx configuration...[/bold blue]")
|
|
812
|
+
try:
|
|
813
|
+
def _reload():
|
|
814
|
+
if shutil.which("systemctl"):
|
|
815
|
+
subprocess.run(["sudo", "systemctl", "reload", "nginx"], check=True)
|
|
816
|
+
else:
|
|
817
|
+
subprocess.run(["sudo", "service", "nginx", "reload"], check=True)
|
|
818
|
+
await asyncio.to_thread(_reload)
|
|
819
|
+
self.log_message("[bold green]✓ Nginx configuration reloaded successfully.[/bold green]")
|
|
820
|
+
self.notify("Nginx reloaded.", severity="information")
|
|
821
|
+
except Exception as e:
|
|
822
|
+
self.log_message(f"[bold red]✗ Failed to reload Nginx: {e}[/bold red]")
|
|
823
|
+
self.notify(f"Nginx reload failed: {e}", severity="error")
|
|
824
|
+
|
|
825
|
+
@work(exclusive=True)
|
|
826
|
+
async def action_check_upgrades(self) -> None:
|
|
827
|
+
"""Query upstream releases and display version check results in the console."""
|
|
828
|
+
self.query_one(TabbedContent).active = "tab-logs"
|
|
829
|
+
self.log_message("[bold cyan]═══════════════════════════════════════════════════[/bold cyan]")
|
|
830
|
+
self.log_message("[bold cyan]🔍 Checking for Stack Component Updates...[/bold cyan]")
|
|
831
|
+
self.log_message("[bold cyan]═══════════════════════════════════════════════════[/bold cyan]")
|
|
832
|
+
self.notify("Checking component versions...", severity="information")
|
|
833
|
+
|
|
834
|
+
try:
|
|
835
|
+
infos = await asyncio.to_thread(upgrade_core.check_all)
|
|
836
|
+
upgradable = []
|
|
837
|
+
for info in infos:
|
|
838
|
+
curr_str = info.current_version or "[dim]Not installed[/dim]"
|
|
839
|
+
latest_str = info.latest_version or "[dim]Unknown[/dim]"
|
|
840
|
+
if not info.installed:
|
|
841
|
+
self.log_message(f"• [yellow]{info.display_name:<28}[/yellow] [dim]Not Installed[/dim] (Latest: {latest_str})")
|
|
842
|
+
elif info.update_available:
|
|
843
|
+
self.log_message(f"• [bold green]{info.display_name:<28}[/bold green] [yellow]{curr_str}[/yellow] -> [bold green]{latest_str}[/bold green] [bold yellow](UPDATE AVAILABLE)[/bold yellow]")
|
|
844
|
+
upgradable.append(info)
|
|
845
|
+
else:
|
|
846
|
+
self.log_message(f"• [bold green]{info.display_name:<28}[/bold green] [dim]{curr_str}[/dim] [green]✓ Up-to-date[/green]")
|
|
847
|
+
|
|
848
|
+
if upgradable:
|
|
849
|
+
names = ", ".join([c.display_name for c in upgradable])
|
|
850
|
+
self.log_message(f"\n[bold yellow]⚡ {len(upgradable)} component(s) can be upgraded: {names}[/bold yellow]")
|
|
851
|
+
self.log_message("[dim]Click 'Upgrade Stack' in the sidebar or press Shift+U to install updates.[/dim]")
|
|
852
|
+
self.notify(f"{len(upgradable)} updates available: {names}", severity="warning")
|
|
853
|
+
else:
|
|
854
|
+
self.log_message("\n[bold green]✓ All installed stack components are up-to-date![/bold green]")
|
|
855
|
+
self.notify("All stack components are up-to-date.", severity="information")
|
|
856
|
+
except Exception as e:
|
|
857
|
+
self.log_message(f"[bold red]✗ Failed to check upgrades: {e}[/bold red]")
|
|
858
|
+
self.notify(f"Check failed: {e}", severity="error")
|
|
859
|
+
|
|
860
|
+
@work(exclusive=True)
|
|
861
|
+
async def action_upgrade_stack(self) -> None:
|
|
862
|
+
"""Check for and upgrade all stack components needing updates."""
|
|
863
|
+
self.query_one(TabbedContent).active = "tab-logs"
|
|
864
|
+
self.log_message("[bold blue]═══════════════════════════════════════════════════[/bold blue]")
|
|
865
|
+
self.log_message("[bold blue]🚀 Starting Stack Component Upgrade Process...[/bold blue]")
|
|
866
|
+
self.log_message("[bold blue]═══════════════════════════════════════════════════[/bold blue]")
|
|
867
|
+
self.notify("Scanning stack components for upgrades...", severity="information")
|
|
868
|
+
|
|
869
|
+
try:
|
|
870
|
+
infos = await asyncio.to_thread(upgrade_core.check_all)
|
|
871
|
+
upgradable = [c for c in infos if c.update_available]
|
|
872
|
+
if not upgradable:
|
|
873
|
+
self.log_message("[bold green]✓ All installed stack components are already up-to-date![/bold green]")
|
|
874
|
+
self.notify("All components are up-to-date.", severity="information")
|
|
875
|
+
return
|
|
876
|
+
|
|
877
|
+
self.log_message(f"[bold cyan]Upgrading {len(upgradable)} component(s)...[/bold cyan]")
|
|
878
|
+
for c in upgradable:
|
|
879
|
+
self.log_message(f"[bold blue]• Upgrading {c.display_name} ({c.current_version} -> {c.latest_version})...[/bold blue]")
|
|
880
|
+
ok, msg = await asyncio.to_thread(upgrade_core.upgrade_component, c.name)
|
|
881
|
+
if ok:
|
|
882
|
+
self.log_message(f"[bold green] ✓ {msg}[/bold green]")
|
|
883
|
+
self.notify(f"{c.display_name} upgraded!", severity="information")
|
|
884
|
+
else:
|
|
885
|
+
self.log_message(f"[bold red] ✗ {msg}[/bold red]")
|
|
886
|
+
self.notify(f"{c.display_name} upgrade failed", severity="error")
|
|
887
|
+
|
|
888
|
+
self.log_message("[bold green]═══════════════════════════════════════════════════[/bold green]")
|
|
889
|
+
self.log_message("[bold green]✓ Upgrade process completed![/bold green]")
|
|
890
|
+
self.log_message("[bold green]═══════════════════════════════════════════════════[/bold green]")
|
|
891
|
+
self._refresh_all_data(full_rebuild=True)
|
|
892
|
+
except Exception as e:
|
|
893
|
+
self.log_message(f"[bold red]✗ Upgrade process failed: {e}[/bold red]")
|
|
894
|
+
self.notify(f"Upgrade failed: {e}", severity="error")
|
|
895
|
+
|
|
896
|
+
# ── BUTTON AND EVENT HANDLERS ─────────────────────────────────────────────
|
|
897
|
+
|
|
898
|
+
@on(Button.Pressed)
|
|
899
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
900
|
+
btn_id = event.button.id
|
|
901
|
+
if btn_id == "btn-start-all":
|
|
902
|
+
self.action_start_all()
|
|
903
|
+
elif btn_id == "btn-stop-all":
|
|
904
|
+
self.action_stop_all()
|
|
905
|
+
elif btn_id == "btn-restart-all":
|
|
906
|
+
self.action_restart_all()
|
|
907
|
+
elif btn_id == "btn-reload-nginx":
|
|
908
|
+
self.action_reload_nginx()
|
|
909
|
+
elif btn_id in ["btn-check-upgrades", "btn-svc-check-upgrades"]:
|
|
910
|
+
self.action_check_upgrades()
|
|
911
|
+
elif btn_id == "btn-upgrade-stack":
|
|
912
|
+
self.action_upgrade_stack()
|
|
913
|
+
elif btn_id in ["btn-sidebar-create-vhost", "btn-vhost-create"]:
|
|
914
|
+
self.action_create_vhost()
|
|
915
|
+
elif btn_id == "btn-vhost-delete":
|
|
916
|
+
self.action_delete_vhost()
|
|
917
|
+
elif btn_id in ["btn-sidebar-install-php", "btn-php-install"]:
|
|
918
|
+
self.action_install_php()
|
|
919
|
+
elif btn_id == "btn-php-uninstall":
|
|
920
|
+
self.action_uninstall_php()
|
|
921
|
+
elif btn_id == "btn-svc-install":
|
|
922
|
+
self._handle_selected_service_install()
|
|
923
|
+
elif btn_id == "btn-tail-log":
|
|
924
|
+
self._handle_tail_log()
|
|
925
|
+
elif btn_id == "btn-clear-console":
|
|
926
|
+
self.query_one("#console-log", RichLog).clear()
|
|
927
|
+
elif btn_id == "btn-svc-start":
|
|
928
|
+
self._handle_selected_service_action("start")
|
|
929
|
+
elif btn_id == "btn-svc-stop":
|
|
930
|
+
self._handle_selected_service_action("stop")
|
|
931
|
+
elif btn_id == "btn-svc-restart":
|
|
932
|
+
self._handle_selected_service_action("restart")
|
|
933
|
+
elif btn_id == "btn-svc-open":
|
|
934
|
+
self._handle_selected_service_open()
|
|
935
|
+
elif btn_id == "btn-vhost-open":
|
|
936
|
+
self._handle_selected_vhost_open()
|
|
937
|
+
elif btn_id == "btn-php-set-active":
|
|
938
|
+
self._handle_php_set_active()
|
|
939
|
+
|
|
940
|
+
# ── VHOST, PHP & SERVICE INSTALL ACTIONS ──────────────────────────────────
|
|
941
|
+
|
|
942
|
+
def action_create_vhost(self) -> None:
|
|
943
|
+
"""Open modal dialog to create a new virtual host."""
|
|
944
|
+
installed = []
|
|
945
|
+
if PHP_DIR.exists():
|
|
946
|
+
installed = sorted([d.name for d in PHP_DIR.iterdir() if d.is_dir()])
|
|
947
|
+
curr = CURRENT_LINK.resolve().name if (CURRENT_LINK.exists() or CURRENT_LINK.is_symlink()) else None
|
|
948
|
+
|
|
949
|
+
def _on_modal_result(res: Optional[dict]) -> None:
|
|
950
|
+
if res:
|
|
951
|
+
self._create_vhost_worker(res["domain"], res["root"], res["php"], res["ssl"])
|
|
952
|
+
|
|
953
|
+
self.push_screen(CreateVhostModal(installed, curr), _on_modal_result)
|
|
954
|
+
|
|
955
|
+
@work(exclusive=True)
|
|
956
|
+
async def _create_vhost_worker(self, domain: str, root: str, php_ver: str, ssl: bool) -> None:
|
|
957
|
+
self.query_one(TabbedContent).active = "tab-logs"
|
|
958
|
+
self.log_message(f"[bold blue]Creating virtual host '{domain}' (PHP {php_ver}, SSL={ssl})...[/bold blue]")
|
|
959
|
+
self.notify(f"Creating vhost {domain}...", severity="information")
|
|
960
|
+
|
|
961
|
+
def _do() -> str:
|
|
962
|
+
from ndev.linux.commands.vhost import generate_local_cert, chown_to_sudo_user
|
|
963
|
+
nginx_available = Path("/etc/nginx/sites-available")
|
|
964
|
+
nginx_enabled = Path("/etc/nginx/sites-enabled")
|
|
965
|
+
if not nginx_available.exists():
|
|
966
|
+
nginx_available = NDEV_DIR / "nginx" / "conf" / "ndev-vhosts"
|
|
967
|
+
nginx_enabled = nginx_available
|
|
968
|
+
nginx_available.mkdir(parents=True, exist_ok=True)
|
|
969
|
+
|
|
970
|
+
conf_file = nginx_available / f"{domain}.conf"
|
|
971
|
+
cert_dir = NDEV_DIR / "certs"
|
|
972
|
+
cert_path, key_path = None, None
|
|
973
|
+
if ssl:
|
|
974
|
+
cert_path, key_path = generate_local_cert(domain, cert_dir)
|
|
975
|
+
|
|
976
|
+
mm = php_ver.replace(".", "")[:2] if "." in php_ver else php_ver
|
|
977
|
+
selected_sock = NDEV_DIR / "run" / f"php{mm}.sock"
|
|
978
|
+
|
|
979
|
+
if ssl:
|
|
980
|
+
tpl = f"""server {{
|
|
981
|
+
listen 443 ssl;
|
|
982
|
+
listen [::]:443 ssl;
|
|
983
|
+
server_name {domain};
|
|
984
|
+
ssl_certificate {cert_path};
|
|
985
|
+
ssl_certificate_key {key_path};
|
|
986
|
+
root {root};
|
|
987
|
+
index index.php index.html index.htm;
|
|
988
|
+
access_log /var/log/nginx/{domain}.access.log;
|
|
989
|
+
error_log /var/log/nginx/{domain}.error.log;
|
|
990
|
+
location / {{
|
|
991
|
+
try_files $uri $uri/ /index.php?$query_string;
|
|
992
|
+
}}
|
|
993
|
+
location ~ \\.php$ {{
|
|
994
|
+
include snippets/fastcgi-php.conf;
|
|
995
|
+
fastcgi_pass unix:{selected_sock};
|
|
996
|
+
}}
|
|
997
|
+
location ~ /\\.ht {{
|
|
998
|
+
deny all;
|
|
999
|
+
}}
|
|
1000
|
+
}}
|
|
1001
|
+
"""
|
|
1002
|
+
else:
|
|
1003
|
+
tpl = f"""server {{
|
|
1004
|
+
listen 80;
|
|
1005
|
+
listen [::]:80;
|
|
1006
|
+
server_name {domain};
|
|
1007
|
+
root {root};
|
|
1008
|
+
index index.php index.html index.htm;
|
|
1009
|
+
access_log /var/log/nginx/{domain}.access.log;
|
|
1010
|
+
error_log /var/log/nginx/{domain}.error.log;
|
|
1011
|
+
location / {{
|
|
1012
|
+
try_files $uri $uri/ /index.php?$query_string;
|
|
1013
|
+
}}
|
|
1014
|
+
location ~ \\.php$ {{
|
|
1015
|
+
include snippets/fastcgi-php.conf;
|
|
1016
|
+
fastcgi_pass unix:{selected_sock};
|
|
1017
|
+
}}
|
|
1018
|
+
location ~ /\\.ht {{
|
|
1019
|
+
deny all;
|
|
1020
|
+
}}
|
|
1021
|
+
}}
|
|
1022
|
+
"""
|
|
1023
|
+
conf_file.write_text(tpl)
|
|
1024
|
+
if nginx_enabled != nginx_available:
|
|
1025
|
+
link = nginx_enabled / f"{domain}.conf"
|
|
1026
|
+
link.unlink(missing_ok=True)
|
|
1027
|
+
link.symlink_to(conf_file)
|
|
1028
|
+
|
|
1029
|
+
# Add /etc/hosts entry if writable
|
|
1030
|
+
try:
|
|
1031
|
+
hosts = Path("/etc/hosts")
|
|
1032
|
+
if hosts.exists():
|
|
1033
|
+
txt = hosts.read_text()
|
|
1034
|
+
if domain not in txt:
|
|
1035
|
+
with hosts.open("a") as hf:
|
|
1036
|
+
hf.write(f"\n127.0.0.1 {domain}\n")
|
|
1037
|
+
except Exception:
|
|
1038
|
+
pass
|
|
1039
|
+
|
|
1040
|
+
# Reload Nginx
|
|
1041
|
+
try:
|
|
1042
|
+
subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=5)
|
|
1043
|
+
except Exception:
|
|
1044
|
+
pass
|
|
1045
|
+
return str(conf_file)
|
|
1046
|
+
|
|
1047
|
+
try:
|
|
1048
|
+
conf_path = await asyncio.to_thread(_do)
|
|
1049
|
+
self.log_message(f"[bold green]✓ Virtual host created successfully: {conf_path}[/bold green]")
|
|
1050
|
+
proto = "https" if ssl else "http"
|
|
1051
|
+
self.log_message(f" • URL: [link={proto}://{domain}]{proto}://{domain}[/link]")
|
|
1052
|
+
self.log_message(f" • Root: {root}")
|
|
1053
|
+
self.notify(f"Virtual host {domain} created!", severity="information")
|
|
1054
|
+
self.query_one(TabbedContent).active = "tab-vhosts"
|
|
1055
|
+
self._refresh_all_data(full_rebuild=True)
|
|
1056
|
+
except Exception as e:
|
|
1057
|
+
self.log_message(f"[bold red]✗ Failed to create virtual host {domain}: {e}[/bold red]")
|
|
1058
|
+
self.notify(f"VHost creation failed: {e}", severity="error")
|
|
1059
|
+
|
|
1060
|
+
def action_delete_vhost(self) -> None:
|
|
1061
|
+
"""Prompt to delete the currently selected virtual host."""
|
|
1062
|
+
vhost_table = self.query_one("#table-vhosts", DataTable)
|
|
1063
|
+
if vhost_table.cursor_row is None or vhost_table.row_count == 0:
|
|
1064
|
+
self.notify("Please select a virtual host row to delete.", severity="warning")
|
|
1065
|
+
return
|
|
1066
|
+
|
|
1067
|
+
row_key = vhost_table.coordinate_to_cell_key((vhost_table.cursor_row, 0)).row_key
|
|
1068
|
+
domain = str(row_key.value)
|
|
1069
|
+
|
|
1070
|
+
def _on_confirm(confirmed: bool) -> None:
|
|
1071
|
+
if confirmed:
|
|
1072
|
+
self._delete_vhost_worker(domain)
|
|
1073
|
+
|
|
1074
|
+
msg = f"Are you sure you want to delete virtual host '{domain}'?\n\nThis will remove its Nginx configuration, SSL certificates, and hosts entry."
|
|
1075
|
+
self.push_screen(ConfirmActionModal("Delete Virtual Host", msg, confirm_label="Delete VHost", variant="error"), _on_confirm)
|
|
1076
|
+
|
|
1077
|
+
@work(exclusive=True)
|
|
1078
|
+
async def _delete_vhost_worker(self, domain: str) -> None:
|
|
1079
|
+
self.log_message(f"[bold yellow]Deleting virtual host '{domain}'...[/bold yellow]")
|
|
1080
|
+
self.notify(f"Deleting vhost {domain}...", severity="information")
|
|
1081
|
+
|
|
1082
|
+
def _do() -> None:
|
|
1083
|
+
for d in [Path("/etc/nginx/sites-enabled"), Path("/etc/nginx/sites-available"), NDEV_DIR / "nginx" / "conf" / "ndev-vhosts"]:
|
|
1084
|
+
f = d / f"{domain}.conf"
|
|
1085
|
+
if f.exists() or f.is_symlink():
|
|
1086
|
+
f.unlink()
|
|
1087
|
+
try:
|
|
1088
|
+
subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=5)
|
|
1089
|
+
except Exception:
|
|
1090
|
+
pass
|
|
1091
|
+
|
|
1092
|
+
try:
|
|
1093
|
+
await asyncio.to_thread(_do)
|
|
1094
|
+
self.log_message(f"[bold green]✓ Virtual host '{domain}' deleted successfully.[/bold green]")
|
|
1095
|
+
self.notify(f"Virtual host {domain} deleted.", severity="information")
|
|
1096
|
+
self._refresh_all_data(full_rebuild=True)
|
|
1097
|
+
except Exception as e:
|
|
1098
|
+
self.log_message(f"[bold red]✗ Failed to delete virtual host {domain}: {e}[/bold red]")
|
|
1099
|
+
self.notify(f"Delete failed: {e}", severity="error")
|
|
1100
|
+
|
|
1101
|
+
def action_install_php(self) -> None:
|
|
1102
|
+
"""Open modal dialog to install a new PHP runtime."""
|
|
1103
|
+
def _on_modal_result(res: Optional[dict]) -> None:
|
|
1104
|
+
if res:
|
|
1105
|
+
self._install_php_worker(res["version"])
|
|
1106
|
+
|
|
1107
|
+
self.push_screen(InstallPhpModal(), _on_modal_result)
|
|
1108
|
+
|
|
1109
|
+
@work(exclusive=True)
|
|
1110
|
+
async def _install_php_worker(self, version: str) -> None:
|
|
1111
|
+
self.query_one(TabbedContent).active = "tab-logs"
|
|
1112
|
+
self.log_message("[bold blue]═══════════════════════════════════════════════════[/bold blue]")
|
|
1113
|
+
self.log_message(f"[bold blue]🐘 Installing PHP {version}...[/bold blue]")
|
|
1114
|
+
self.notify(f"Installing PHP {version}...", severity="information")
|
|
1115
|
+
|
|
1116
|
+
try:
|
|
1117
|
+
from ndev.linux.php.installer import install_version
|
|
1118
|
+
resolved = await asyncio.to_thread(install_version, version)
|
|
1119
|
+
self.log_message(f"[bold green]✓ PHP {resolved} installed successfully![/bold green]")
|
|
1120
|
+
self.notify(f"PHP {resolved} installed!", severity="information")
|
|
1121
|
+
self.query_one(TabbedContent).active = "tab-php"
|
|
1122
|
+
self._refresh_all_data(full_rebuild=True)
|
|
1123
|
+
except Exception as e:
|
|
1124
|
+
self.log_message(f"[bold red]✗ PHP installation failed: {e}[/bold red]")
|
|
1125
|
+
self.notify(f"Install failed: {e}", severity="error")
|
|
1126
|
+
|
|
1127
|
+
def action_uninstall_php(self) -> None:
|
|
1128
|
+
"""Prompt to uninstall the currently selected PHP runtime."""
|
|
1129
|
+
php_table = self.query_one("#table-php", DataTable)
|
|
1130
|
+
if php_table.cursor_row is None or php_table.row_count == 0:
|
|
1131
|
+
self.notify("Please select a PHP version row to uninstall.", severity="warning")
|
|
1132
|
+
return
|
|
1133
|
+
|
|
1134
|
+
row_key = php_table.coordinate_to_cell_key((php_table.cursor_row, 0)).row_key
|
|
1135
|
+
ver = str(row_key.value)
|
|
1136
|
+
|
|
1137
|
+
def _on_confirm(confirmed: bool) -> None:
|
|
1138
|
+
if confirmed:
|
|
1139
|
+
self._uninstall_php_worker(ver)
|
|
1140
|
+
|
|
1141
|
+
msg = f"Are you sure you want to uninstall PHP {ver}?\n\nThis will stop its PHP-FPM pool and remove the installation directory."
|
|
1142
|
+
self.push_screen(ConfirmActionModal("Uninstall PHP Runtime", msg, confirm_label="Uninstall PHP", variant="error"), _on_confirm)
|
|
1143
|
+
|
|
1144
|
+
@work(exclusive=True)
|
|
1145
|
+
async def _uninstall_php_worker(self, version: str) -> None:
|
|
1146
|
+
self.log_message(f"[bold yellow]Uninstalling PHP {version}...[/bold yellow]")
|
|
1147
|
+
self.notify(f"Uninstalling PHP {version}...", severity="information")
|
|
1148
|
+
|
|
1149
|
+
def _do() -> None:
|
|
1150
|
+
stop_fpm(version)
|
|
1151
|
+
prefix = PHP_DIR / version
|
|
1152
|
+
if prefix.exists():
|
|
1153
|
+
shutil.rmtree(prefix)
|
|
1154
|
+
if CURRENT_LINK.exists() and CURRENT_LINK.is_symlink():
|
|
1155
|
+
if CURRENT_LINK.resolve() == prefix.resolve():
|
|
1156
|
+
CURRENT_LINK.unlink()
|
|
1157
|
+
|
|
1158
|
+
try:
|
|
1159
|
+
await asyncio.to_thread(_do)
|
|
1160
|
+
self.log_message(f"[bold green]✓ PHP {version} uninstalled successfully.[/bold green]")
|
|
1161
|
+
self.notify(f"PHP {version} uninstalled.", severity="information")
|
|
1162
|
+
self._refresh_all_data(full_rebuild=True)
|
|
1163
|
+
except Exception as e:
|
|
1164
|
+
self.log_message(f"[bold red]✗ Failed to uninstall PHP {version}: {e}[/bold red]")
|
|
1165
|
+
self.notify(f"Uninstall failed: {e}", severity="error")
|
|
1166
|
+
|
|
1167
|
+
def _handle_selected_service_install(self) -> None:
|
|
1168
|
+
"""Install or setup the selected service (PMA, Mailpit, Nginx, MariaDB)."""
|
|
1169
|
+
svc_table = self.query_one("#table-services", DataTable)
|
|
1170
|
+
if svc_table.cursor_row is not None and svc_table.row_count > 0:
|
|
1171
|
+
row_key = svc_table.coordinate_to_cell_key((svc_table.cursor_row, 0)).row_key
|
|
1172
|
+
self._selected_service_key = str(row_key.value)
|
|
1173
|
+
|
|
1174
|
+
key = self._selected_service_key
|
|
1175
|
+
if not key:
|
|
1176
|
+
self.notify("Please select a service row from the table.", severity="warning")
|
|
1177
|
+
return
|
|
1178
|
+
|
|
1179
|
+
self._install_service_worker(key)
|
|
1180
|
+
|
|
1181
|
+
@work(exclusive=True)
|
|
1182
|
+
async def _install_service_worker(self, key: str) -> None:
|
|
1183
|
+
self.query_one(TabbedContent).active = "tab-logs"
|
|
1184
|
+
self.log_message(f"[bold blue]Installing/setting up component '{key}'...[/bold blue]")
|
|
1185
|
+
self.notify(f"Installing {key}...", severity="information")
|
|
1186
|
+
|
|
1187
|
+
def _do() -> None:
|
|
1188
|
+
if key == "pma":
|
|
1189
|
+
setup_pma()
|
|
1190
|
+
elif key == "mailpit":
|
|
1191
|
+
setup_mailpit()
|
|
1192
|
+
elif key == "nginx":
|
|
1193
|
+
if shutil.which("apt"):
|
|
1194
|
+
subprocess.run(["sudo", "apt-get", "install", "-y", "nginx"], check=True)
|
|
1195
|
+
elif key == "mariadb":
|
|
1196
|
+
if shutil.which("apt"):
|
|
1197
|
+
subprocess.run(["sudo", "apt-get", "install", "-y", "mariadb-server"], check=True)
|
|
1198
|
+
|
|
1199
|
+
try:
|
|
1200
|
+
await asyncio.to_thread(_do)
|
|
1201
|
+
self.log_message(f"[bold green]✓ Component '{key}' installed and configured successfully![/bold green]")
|
|
1202
|
+
self.notify(f"{key} installed successfully!", severity="information")
|
|
1203
|
+
self._refresh_all_data(full_rebuild=True)
|
|
1204
|
+
except Exception as e:
|
|
1205
|
+
self.log_message(f"[bold red]✗ Failed to install {key}: {e}[/bold red]")
|
|
1206
|
+
self.notify(f"Installation failed: {e}", severity="error")
|
|
1207
|
+
|
|
1208
|
+
@work(exclusive=True)
|
|
1209
|
+
async def on_select_changed(self, event: Select.Changed) -> None:
|
|
1210
|
+
"""Handle PHP version switcher or log selector dropdown changes."""
|
|
1211
|
+
if self._updating_selects:
|
|
1212
|
+
return
|
|
1213
|
+
|
|
1214
|
+
if event.select.id == "select-php-version" and event.value != Select.BLANK:
|
|
1215
|
+
target_version = str(event.value)
|
|
1216
|
+
curr = CURRENT_LINK.resolve().name if (CURRENT_LINK.exists() or CURRENT_LINK.is_symlink()) else None
|
|
1217
|
+
if target_version != curr:
|
|
1218
|
+
await self._switch_php_version(target_version)
|
|
1219
|
+
|
|
1220
|
+
elif event.select.id == "select-log-file" and event.value != Select.BLANK:
|
|
1221
|
+
self._handle_tail_log()
|
|
1222
|
+
|
|
1223
|
+
def _handle_tail_log(self) -> None:
|
|
1224
|
+
"""Read and display the tail of the selected log file."""
|
|
1225
|
+
self.query_one(TabbedContent).active = "tab-logs"
|
|
1226
|
+
log_select = self.query_one("#select-log-file", Select)
|
|
1227
|
+
log_name = log_select.value
|
|
1228
|
+
if not log_name or log_name == Select.BLANK:
|
|
1229
|
+
if self._available_logs:
|
|
1230
|
+
first_key = sorted(self._available_logs.keys())[0]
|
|
1231
|
+
self._updating_selects = True
|
|
1232
|
+
try:
|
|
1233
|
+
log_select.value = first_key
|
|
1234
|
+
finally:
|
|
1235
|
+
self._updating_selects = False
|
|
1236
|
+
log_name = first_key
|
|
1237
|
+
else:
|
|
1238
|
+
self.notify("No log files are currently available.", severity="warning")
|
|
1239
|
+
return
|
|
1240
|
+
|
|
1241
|
+
log_path = self._available_logs.get(str(log_name))
|
|
1242
|
+
if not log_path or not log_path.exists():
|
|
1243
|
+
self.log_message(f"[yellow]Log file '{log_name}' not found at {log_path}[/yellow]")
|
|
1244
|
+
return
|
|
1245
|
+
|
|
1246
|
+
self.log_message(f"[bold cyan]─── Tail: {log_name} ({log_path}) ───[/bold cyan]")
|
|
1247
|
+
try:
|
|
1248
|
+
lines = log_path.read_text(errors="ignore").splitlines()[-60:]
|
|
1249
|
+
if not lines:
|
|
1250
|
+
self.log_message("[dim](Log file is empty)[/dim]")
|
|
1251
|
+
else:
|
|
1252
|
+
for line in lines:
|
|
1253
|
+
self.log_message(f"[dim]{line}[/dim]")
|
|
1254
|
+
except Exception as e:
|
|
1255
|
+
self.log_message(f"[bold red]Error reading log: {e}[/bold red]")
|
|
1256
|
+
|
|
1257
|
+
# ── ROW SELECTION & PER-SERVICE ACTIONS ───────────────────────────────────
|
|
1258
|
+
|
|
1259
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
1260
|
+
"""Record the active row under cursor during keyboard navigation."""
|
|
1261
|
+
if event.data_table.id == "table-services" and event.row_key.value:
|
|
1262
|
+
self._selected_service_key = str(event.row_key.value)
|
|
1263
|
+
|
|
1264
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
1265
|
+
"""Handle Enter key or double click on table rows."""
|
|
1266
|
+
table_id = event.data_table.id
|
|
1267
|
+
row_key = str(event.row_key.value) if event.row_key.value else ""
|
|
1268
|
+
|
|
1269
|
+
if table_id == "table-services":
|
|
1270
|
+
self._selected_service_key = row_key
|
|
1271
|
+
if row_key in ["pma", "mailpit"]:
|
|
1272
|
+
self._handle_selected_service_open()
|
|
1273
|
+
else:
|
|
1274
|
+
self._handle_selected_service_action("restart")
|
|
1275
|
+
|
|
1276
|
+
elif table_id == "table-vhosts":
|
|
1277
|
+
self._handle_selected_vhost_open()
|
|
1278
|
+
|
|
1279
|
+
elif table_id == "table-php":
|
|
1280
|
+
if row_key:
|
|
1281
|
+
self._switch_php_version(row_key)
|
|
1282
|
+
|
|
1283
|
+
@work(exclusive=True)
|
|
1284
|
+
async def _handle_selected_service_action(self, action: str) -> None:
|
|
1285
|
+
"""Contextually start, stop, or restart the service highlighted in the table."""
|
|
1286
|
+
svc_table = self.query_one("#table-services", DataTable)
|
|
1287
|
+
if svc_table.cursor_row is not None and svc_table.row_count > 0:
|
|
1288
|
+
row_key = svc_table.coordinate_to_cell_key((svc_table.cursor_row, 0)).row_key
|
|
1289
|
+
self._selected_service_key = str(row_key.value)
|
|
1290
|
+
|
|
1291
|
+
key = self._selected_service_key
|
|
1292
|
+
if not key:
|
|
1293
|
+
self.notify("Please select a service row from the table.", severity="warning")
|
|
1294
|
+
return
|
|
1295
|
+
|
|
1296
|
+
self.log_message(f"[bold blue]Executing '{action}' on {key}...[/bold blue]")
|
|
1297
|
+
|
|
1298
|
+
def _do() -> None:
|
|
1299
|
+
if key == "nginx":
|
|
1300
|
+
if shutil.which("systemctl"):
|
|
1301
|
+
subprocess.run(["sudo", "systemctl", action, "nginx"], check=True)
|
|
1302
|
+
else:
|
|
1303
|
+
subprocess.run(["sudo", "service", "nginx", action], check=True)
|
|
1304
|
+
|
|
1305
|
+
elif key == "mariadb":
|
|
1306
|
+
if shutil.which("systemctl"):
|
|
1307
|
+
subprocess.run(["sudo", "systemctl", action, "mariadb"], check=True)
|
|
1308
|
+
else:
|
|
1309
|
+
subprocess.run(["sudo", "service", "mariadb", action], check=True)
|
|
1310
|
+
|
|
1311
|
+
elif key == "pma":
|
|
1312
|
+
if action == "start":
|
|
1313
|
+
start_pma()
|
|
1314
|
+
elif action == "stop":
|
|
1315
|
+
stop_pma()
|
|
1316
|
+
elif action == "restart":
|
|
1317
|
+
restart_pma()
|
|
1318
|
+
|
|
1319
|
+
elif key == "mailpit":
|
|
1320
|
+
if action == "start":
|
|
1321
|
+
start_mailpit()
|
|
1322
|
+
elif action == "stop":
|
|
1323
|
+
stop_mailpit()
|
|
1324
|
+
elif action == "restart":
|
|
1325
|
+
restart_mailpit()
|
|
1326
|
+
|
|
1327
|
+
elif key.startswith("php:"):
|
|
1328
|
+
ver = key.split(":", 1)[1]
|
|
1329
|
+
if action == "start":
|
|
1330
|
+
start_fpm(ver)
|
|
1331
|
+
elif action == "stop":
|
|
1332
|
+
stop_fpm(ver)
|
|
1333
|
+
elif action == "restart":
|
|
1334
|
+
restart_fpm(ver)
|
|
1335
|
+
|
|
1336
|
+
try:
|
|
1337
|
+
await asyncio.to_thread(_do)
|
|
1338
|
+
self.log_message(f"[bold green]✓ '{action}' completed for {key}.[/bold green]")
|
|
1339
|
+
self.notify(f"{key} {action} completed.", severity="information")
|
|
1340
|
+
except Exception as e:
|
|
1341
|
+
self.log_message(f"[bold red]✗ Action '{action}' on {key} failed: {e}[/bold red]")
|
|
1342
|
+
self.notify(f"Action failed: {e}", severity="error")
|
|
1343
|
+
finally:
|
|
1344
|
+
self._refresh_all_data(full_rebuild=False)
|
|
1345
|
+
|
|
1346
|
+
def _handle_selected_service_open(self) -> None:
|
|
1347
|
+
"""Open web UI for phpMyAdmin or Mailpit if selected."""
|
|
1348
|
+
svc_table = self.query_one("#table-services", DataTable)
|
|
1349
|
+
if svc_table.cursor_row is not None and svc_table.row_count > 0:
|
|
1350
|
+
row_key = svc_table.coordinate_to_cell_key((svc_table.cursor_row, 0)).row_key
|
|
1351
|
+
self._selected_service_key = str(row_key.value)
|
|
1352
|
+
|
|
1353
|
+
key = self._selected_service_key
|
|
1354
|
+
if key == "pma":
|
|
1355
|
+
st = get_pma_status()
|
|
1356
|
+
url = st.get("url") or "http://127.0.0.1:8080"
|
|
1357
|
+
webbrowser.open(url)
|
|
1358
|
+
self.log_message(f"Opening phpMyAdmin at {url}")
|
|
1359
|
+
elif key == "mailpit":
|
|
1360
|
+
st = get_mailpit_status()
|
|
1361
|
+
url = st.get("url") or "http://127.0.0.1:8025"
|
|
1362
|
+
webbrowser.open(url)
|
|
1363
|
+
self.log_message(f"Opening Mailpit at {url}")
|
|
1364
|
+
elif key == "nginx":
|
|
1365
|
+
webbrowser.open("http://127.0.0.1")
|
|
1366
|
+
self.log_message("Opening Nginx at http://127.0.0.1")
|
|
1367
|
+
else:
|
|
1368
|
+
self.notify("Selected service does not provide a web interface.", severity="information")
|
|
1369
|
+
|
|
1370
|
+
def _handle_selected_vhost_open(self) -> None:
|
|
1371
|
+
"""Open highlighted virtual host in default web browser."""
|
|
1372
|
+
vhost_table = self.query_one("#table-vhosts", DataTable)
|
|
1373
|
+
if vhost_table.cursor_row is not None and vhost_table.row_count > 0:
|
|
1374
|
+
row_key = vhost_table.coordinate_to_cell_key((vhost_table.cursor_row, 0)).row_key
|
|
1375
|
+
domain = str(row_key.value)
|
|
1376
|
+
webbrowser.open(f"http://{domain}")
|
|
1377
|
+
self.log_message(f"Opening virtual host: http://{domain}")
|
|
1378
|
+
|
|
1379
|
+
def _handle_php_set_active(self) -> None:
|
|
1380
|
+
"""Set the highlighted PHP version as the active CLI version."""
|
|
1381
|
+
php_table = self.query_one("#table-php", DataTable)
|
|
1382
|
+
if php_table.cursor_row is not None and php_table.row_count > 0:
|
|
1383
|
+
row_key = php_table.coordinate_to_cell_key((php_table.cursor_row, 0)).row_key
|
|
1384
|
+
ver = str(row_key.value)
|
|
1385
|
+
self._switch_php_version(ver)
|
|
1386
|
+
|
|
1387
|
+
@work(exclusive=True)
|
|
1388
|
+
async def _switch_php_version(self, target_version: str) -> None:
|
|
1389
|
+
def _use() -> None:
|
|
1390
|
+
target = PHP_DIR / target_version
|
|
1391
|
+
if not target.exists():
|
|
1392
|
+
raise RuntimeError(f"PHP {target_version} is not installed.")
|
|
1393
|
+
if CURRENT_LINK.exists() or CURRENT_LINK.is_symlink():
|
|
1394
|
+
CURRENT_LINK.unlink()
|
|
1395
|
+
CURRENT_LINK.symlink_to(target)
|
|
1396
|
+
|
|
1397
|
+
local_bin = Path(os.path.expanduser("~/.local/bin"))
|
|
1398
|
+
local_bin.mkdir(parents=True, exist_ok=True)
|
|
1399
|
+
for bin_name in ["php", "php-config", "phpize", "php-fpm", "composer"]:
|
|
1400
|
+
src = target / "bin" / bin_name
|
|
1401
|
+
if not src.exists() and bin_name == "php-fpm":
|
|
1402
|
+
src = target / "sbin" / bin_name
|
|
1403
|
+
dst = local_bin / bin_name
|
|
1404
|
+
if src.exists():
|
|
1405
|
+
if dst.exists() or dst.is_symlink():
|
|
1406
|
+
dst.unlink()
|
|
1407
|
+
dst.symlink_to(src)
|
|
1408
|
+
|
|
1409
|
+
self.log_message(f"[bold blue]Switching active CLI PHP to {target_version}...[/bold blue]")
|
|
1410
|
+
try:
|
|
1411
|
+
await asyncio.to_thread(_use)
|
|
1412
|
+
self.log_message(f"[bold green]✓ Active PHP is now {target_version}[/bold green]")
|
|
1413
|
+
self.notify(f"Active PHP set to {target_version}", severity="information")
|
|
1414
|
+
self._refresh_all_data(full_rebuild=True)
|
|
1415
|
+
except Exception as e:
|
|
1416
|
+
self.log_message(f"[bold red]✗ Failed to switch PHP: {e}[/bold red]")
|
|
1417
|
+
self.notify(f"PHP switch failed: {e}", severity="error")
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def run_dashboard() -> None:
|
|
1421
|
+
"""Entrypoint to launch the Textual TUI app."""
|
|
1422
|
+
app = NdevDashboard()
|
|
1423
|
+
app.run()
|