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.
Files changed (79) hide show
  1. ndev/__init__.py +8 -0
  2. ndev/__main__.py +4 -0
  3. ndev/cli.py +24 -0
  4. ndev/common/__init__.py +3 -0
  5. ndev/common/config.py +114 -0
  6. ndev/common/constants.py +51 -0
  7. ndev/common/github.py +13 -0
  8. ndev/common/logger.py +11 -0
  9. ndev/common/manifest.py +41 -0
  10. ndev/common/utils.py +96 -0
  11. ndev/linux/__init__.py +1 -0
  12. ndev/linux/chroot/manager.py +63 -0
  13. ndev/linux/chroot/packages.py +91 -0
  14. ndev/linux/chroot/shell.py +9 -0
  15. ndev/linux/cli.py +235 -0
  16. ndev/linux/commands/available.py +38 -0
  17. ndev/linux/commands/clean.py +25 -0
  18. ndev/linux/commands/ctl.py +192 -0
  19. ndev/linux/commands/current.py +11 -0
  20. ndev/linux/commands/db.py +319 -0
  21. ndev/linux/commands/doctor.py +56 -0
  22. ndev/linux/commands/grok.py +75 -0
  23. ndev/linux/commands/install.py +39 -0
  24. ndev/linux/commands/list.py +47 -0
  25. ndev/linux/commands/logs.py +36 -0
  26. ndev/linux/commands/mailpit.py +82 -0
  27. ndev/linux/commands/reload.py +26 -0
  28. ndev/linux/commands/restart.py +34 -0
  29. ndev/linux/commands/setup.py +113 -0
  30. ndev/linux/commands/start.py +34 -0
  31. ndev/linux/commands/status.py +81 -0
  32. ndev/linux/commands/stop.py +34 -0
  33. ndev/linux/commands/uninstall.py +69 -0
  34. ndev/linux/commands/update.py +57 -0
  35. ndev/linux/commands/upgrade.py +81 -0
  36. ndev/linux/commands/use.py +108 -0
  37. ndev/linux/commands/vhost.py +350 -0
  38. ndev/linux/php/builder.py +183 -0
  39. ndev/linux/php/downloader.py +58 -0
  40. ndev/linux/php/extensions.py +146 -0
  41. ndev/linux/php/installer.py +42 -0
  42. ndev/linux/php/resolver.py +59 -0
  43. ndev/linux/php/templates.py +128 -0
  44. ndev/linux/runtime/fpm.py +117 -0
  45. ndev/linux/runtime/mailpit.py +244 -0
  46. ndev/linux/runtime/pma.py +223 -0
  47. ndev/linux/runtime/process.py +37 -0
  48. ndev/linux/runtime/sockets.py +16 -0
  49. ndev/linux/runtime/upgrade.py +431 -0
  50. ndev/linux/tui.py +1423 -0
  51. ndev/main.py +52 -0
  52. ndev/tui.py +23 -0
  53. ndev/win/__init__.py +1 -0
  54. ndev/win/cli.py +1898 -0
  55. ndev/win/commands/__init__.py +0 -0
  56. ndev/win/core/__init__.py +0 -0
  57. ndev/win/core/db.py +265 -0
  58. ndev/win/core/elevate.py +94 -0
  59. ndev/win/core/ext.py +241 -0
  60. ndev/win/core/fcgi.py +216 -0
  61. ndev/win/core/grok.py +55 -0
  62. ndev/win/core/logs.py +66 -0
  63. ndev/win/core/mailpit.py +236 -0
  64. ndev/win/core/mkcert.py +65 -0
  65. ndev/win/core/paths.py +85 -0
  66. ndev/win/core/php.py +533 -0
  67. ndev/win/core/pma.py +190 -0
  68. ndev/win/core/services.py +349 -0
  69. ndev/win/core/setup.py +361 -0
  70. ndev/win/core/upgrade.py +513 -0
  71. ndev/win/core/vhost.py +289 -0
  72. ndev/win/templates/vhost.conf.tmpl +33 -0
  73. ndev/win/templates/vhost_ssl.conf.tmpl +43 -0
  74. ndev/win/tui.py +1313 -0
  75. ndev_stack-0.1.0.dist-info/METADATA +553 -0
  76. ndev_stack-0.1.0.dist-info/RECORD +79 -0
  77. ndev_stack-0.1.0.dist-info/WHEEL +5 -0
  78. ndev_stack-0.1.0.dist-info/entry_points.txt +4 -0
  79. 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()