874 lines
30 KiB
Python
874 lines
30 KiB
Python
"""
|
||
Modern Interactive Terminal User Interface (TUI) for Elemes LMS.
|
||
Multi-OS compatible (Linux, macOS, Windows) using Rich & native key reader.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||
|
||
from rich.align import Align
|
||
from rich.box import ROUNDED, SIMPLE
|
||
from rich.console import Console
|
||
from rich.panel import Panel
|
||
from rich.table import Table
|
||
from rich.text import Text
|
||
|
||
from .commands import (
|
||
cmd_dbbackup,
|
||
cmd_dbrestore,
|
||
cmd_dbstatus,
|
||
cmd_dbupgrade,
|
||
cmd_docs_validate,
|
||
cmd_exportall,
|
||
cmd_importall,
|
||
cmd_init,
|
||
cmd_run,
|
||
cmd_runbuild,
|
||
cmd_runclearbuild,
|
||
cmd_stop,
|
||
cmd_stopclean,
|
||
cmd_teacher,
|
||
cmd_test_all,
|
||
cmd_test_integration,
|
||
cmd_test_smoke,
|
||
cmd_test_unit,
|
||
cmd_test_worker,
|
||
cmd_velxio_run,
|
||
cmd_velxio_runbuild,
|
||
cmd_velxio_status,
|
||
cmd_velxio_stop,
|
||
cmd_velxio_test,
|
||
cmd_verify,
|
||
list_backups,
|
||
)
|
||
from .config import (
|
||
ENV_FILE,
|
||
PARENT_DIR,
|
||
PROJECT_NAME,
|
||
env_val,
|
||
generate_ts_config,
|
||
is_verbose,
|
||
read_env,
|
||
record_last_run,
|
||
toggle_verbose,
|
||
)
|
||
from .runner import run_container_cmd
|
||
|
||
console = Console()
|
||
|
||
|
||
# ─── Cross-Platform Key Reader ───────────────────────────────────────────────
|
||
|
||
def read_key() -> str:
|
||
"""
|
||
Read a single keypress or arrow key cross-platform.
|
||
Returns: 'UP', 'DOWN', 'LEFT', 'RIGHT', 'ENTER', 'ESC', or character string.
|
||
"""
|
||
if sys.platform == "win32":
|
||
try:
|
||
import msvcrt
|
||
|
||
ch = msvcrt.getch()
|
||
if ch in (b"\x00", b"\xe0"):
|
||
ch2 = msvcrt.getch()
|
||
if ch2 == b"H":
|
||
return "UP"
|
||
elif ch2 == b"P":
|
||
return "DOWN"
|
||
elif ch2 == b"K":
|
||
return "LEFT"
|
||
elif ch2 == b"M":
|
||
return "RIGHT"
|
||
return ""
|
||
if ch in (b"\r", b"\n"):
|
||
return "ENTER"
|
||
if ch == b"\x1b":
|
||
return "ESC"
|
||
if ch == b"\x03": # Ctrl+C
|
||
raise KeyboardInterrupt()
|
||
return ch.decode("utf-8", errors="ignore")
|
||
except Exception:
|
||
return ""
|
||
|
||
# POSIX (Linux, macOS)
|
||
if not sys.stdin.isatty():
|
||
try:
|
||
line = sys.stdin.readline()
|
||
return line.strip().lower()
|
||
except Exception:
|
||
return "q"
|
||
|
||
import select
|
||
import termios
|
||
import tty
|
||
|
||
fd = sys.stdin.fileno()
|
||
old_settings = termios.tcgetattr(fd)
|
||
try:
|
||
tty.setraw(fd)
|
||
raw = os.read(fd, 1)
|
||
if not raw:
|
||
return ""
|
||
|
||
if raw == b"\x1b":
|
||
# Check for escape sequences without Python TextIOWrapper buffering
|
||
r, _, _ = select.select([fd], [], [], 0.05)
|
||
if not r:
|
||
return "ESC"
|
||
seq = os.read(fd, 10)
|
||
if seq in (b"[A", b"OA") or seq.endswith(b"A"):
|
||
return "UP"
|
||
elif seq in (b"[B", b"OB") or seq.endswith(b"B"):
|
||
return "DOWN"
|
||
elif seq in (b"[C", b"OC") or seq.endswith(b"C"):
|
||
return "RIGHT"
|
||
elif seq in (b"[D", b"OD") or seq.endswith(b"D"):
|
||
return "LEFT"
|
||
return "ESC"
|
||
elif raw in (b"\r", b"\n"):
|
||
return "ENTER"
|
||
elif raw == b"\x03": # Ctrl+C
|
||
raise KeyboardInterrupt()
|
||
elif raw == b"\x04": # Ctrl+D
|
||
return "q"
|
||
|
||
return raw.decode("utf-8", errors="ignore")
|
||
finally:
|
||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||
|
||
|
||
def flush_input() -> None:
|
||
"""Flush any pending unread characters in stdin to avoid keystroke bleed-through."""
|
||
try:
|
||
if sys.platform == "win32":
|
||
import msvcrt
|
||
while msvcrt.kbhit():
|
||
msvcrt.getch()
|
||
else:
|
||
if sys.stdin.isatty():
|
||
import termios
|
||
termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ─── Live Service Status ─────────────────────────────────────────────────────
|
||
|
||
def get_services_status() -> Dict[str, str]:
|
||
"""Inspect status of core containers for the dashboard."""
|
||
status_map: Dict[str, str] = {
|
||
"postgres": "stopped",
|
||
"elemes": "stopped",
|
||
"elemes-frontend": "stopped",
|
||
"compiler-worker": "stopped",
|
||
"flowchart": "stopped",
|
||
"velxio": "stopped",
|
||
}
|
||
|
||
try:
|
||
ret, out, _ = run_container_cmd(
|
||
[
|
||
"ps",
|
||
"-a",
|
||
"--filter",
|
||
f"label=io.podman.compose.project={PROJECT_NAME}",
|
||
"--format",
|
||
"{{.Names}}|{{.Status}}|{{.Labels}}",
|
||
],
|
||
capture_stdout=True,
|
||
)
|
||
if ret == 0 and out.strip():
|
||
import re
|
||
|
||
for line in out.strip().splitlines():
|
||
parts = line.split("|")
|
||
if len(parts) >= 3:
|
||
cstatus = parts[1].lower()
|
||
clabels = parts[2]
|
||
m = re.search(r"com\.docker\.compose\.service[:=]([a-zA-Z0-9_.-]+)", clabels)
|
||
if m:
|
||
svc = m.group(1)
|
||
if "up" in cstatus:
|
||
status_map[svc] = "healthy" if "healthy" in cstatus else "running"
|
||
elif "exit" in cstatus:
|
||
status_map[svc] = "stopped"
|
||
else:
|
||
status_map[svc] = cstatus
|
||
except Exception:
|
||
pass
|
||
|
||
return status_map
|
||
|
||
|
||
# ─── UI Components ───────────────────────────────────────────────────────────
|
||
|
||
def render_header(services_status: Optional[Dict[str, str]] = None) -> None:
|
||
"""Render the stylish top hero banner and live status bar."""
|
||
title_text = Text()
|
||
title_text.append("⚡ ELEMES LMS ORCHESTRATOR ⚡\n", style="bold cyan")
|
||
title_text.append("Modern Multi-OS Management Suite for Elemes LMS", style="dim white")
|
||
|
||
banner = Panel(
|
||
Align.center(title_text),
|
||
box=ROUNDED,
|
||
border_style="cyan",
|
||
padding=(0, 2),
|
||
)
|
||
console.print(banner)
|
||
|
||
# Status Bar
|
||
env_exists = ENV_FILE.is_file()
|
||
env_badge = "[bold green]OK (.env)[/bold green]" if env_exists else "[bold red]Missing (.env)[/bold red]"
|
||
velxio_mode = env_val("VELXIO_MODE", "local")
|
||
verbose_badge = "[bold green]ON[/bold green]" if is_verbose() else "[dim white]OFF[/dim white]"
|
||
|
||
info_text = (
|
||
f" [bold]Proyek:[/bold] [cyan]{PROJECT_NAME}[/cyan] │ "
|
||
f"[bold]Env:[/bold] {env_badge} │ "
|
||
f"[bold]Velxio Mode:[/bold] [yellow]{velxio_mode}[/yellow] │ "
|
||
f"[bold]Verbose:[/bold] {verbose_badge} [dim]([t] toggle)[/dim] │ "
|
||
f"[bold]OS:[/bold] [magenta]{sys.platform}[/magenta]"
|
||
)
|
||
console.print(info_text)
|
||
|
||
# Mini Service Grid
|
||
if services_status:
|
||
grid = Table(box=SIMPLE, show_header=False, padding=(0, 1), collapse_padding=True)
|
||
grid.add_column(style="dim")
|
||
for _ in range(6):
|
||
grid.add_column()
|
||
|
||
def badge(name: str, key: str) -> str:
|
||
st = services_status.get(key, "stopped")
|
||
if st in ("running", "healthy"):
|
||
return f"[bold green]●[/bold green] [white]{name}[/white]"
|
||
else:
|
||
return f"[bold red]✖[/bold red] [dim]{name}[/dim]"
|
||
|
||
grid.add_row(
|
||
"Layanan:",
|
||
badge("postgres", "postgres"),
|
||
badge("backend", "elemes"),
|
||
badge("frontend", "elemes-frontend"),
|
||
badge("worker", "compiler-worker"),
|
||
badge("flowchart", "flowchart"),
|
||
badge("velxio", "velxio"),
|
||
)
|
||
console.print(grid)
|
||
|
||
console.print("─" * console.width, style="dim")
|
||
|
||
|
||
def run_action_screen(title: str, func: Callable[[], int], command_label: str) -> None:
|
||
"""Display an action execution view, stream output, and show summary."""
|
||
console.clear()
|
||
console.print(
|
||
Panel(
|
||
f"[bold yellow]🚀 Menjalankan:[/bold yellow] [bold white]{title}[/bold white]\n"
|
||
f"[dim]Perintah: {command_label}[/dim]",
|
||
box=ROUNDED,
|
||
border_style="yellow",
|
||
)
|
||
)
|
||
console.print()
|
||
|
||
start_time = time.time()
|
||
try:
|
||
ret = func()
|
||
except KeyboardInterrupt:
|
||
console.print("\n[yellow]⚠️ Dibatalkan oleh pengguna.[/yellow]")
|
||
ret = 130
|
||
except Exception as e:
|
||
console.print(f"\n[red]❌ Terjadi kesalahan: {e}[/red]")
|
||
ret = 1
|
||
|
||
duration = time.time() - start_time
|
||
record_last_run(command_label)
|
||
|
||
console.print()
|
||
if ret == 0:
|
||
summary_panel = Panel(
|
||
f"[bold green]✅ Selesai dengan sukses![/bold green] (Durasi: {duration:.2f} detik)",
|
||
border_style="green",
|
||
box=ROUNDED,
|
||
)
|
||
else:
|
||
summary_panel = Panel(
|
||
f"[bold red]❌ Perintah gagal dengan exit code: {ret}[/bold red] (Durasi: {duration:.2f} detik)",
|
||
border_style="red",
|
||
box=ROUNDED,
|
||
)
|
||
console.print(summary_panel)
|
||
|
||
flush_input()
|
||
console.print("\n[dim]Tekan [ENTER] atau [b] untuk kembali ke menu...[/dim]", end="")
|
||
try:
|
||
while True:
|
||
k = read_key()
|
||
if k in ("ENTER", "ESC", "q", "Q", "b", "B", " "):
|
||
break
|
||
except KeyboardInterrupt:
|
||
pass
|
||
flush_input()
|
||
|
||
|
||
# ─── Interactive Backup Selector ─────────────────────────────────────────────
|
||
|
||
def interactive_restore_screen() -> int:
|
||
"""Show list of backups and allow selecting which one to restore."""
|
||
backups = list_backups()
|
||
if not backups:
|
||
console.clear()
|
||
console.print(
|
||
Panel(
|
||
"[bold red]❌ Tidak ada file backup ditemukan di backups/elemes_*.sql[/bold red]",
|
||
border_style="red",
|
||
box=ROUNDED,
|
||
)
|
||
)
|
||
console.print("\nTekan [ENTER] untuk kembali...")
|
||
read_key()
|
||
return 1
|
||
|
||
current_idx = 0
|
||
while True:
|
||
console.clear()
|
||
render_header()
|
||
console.print(
|
||
Panel(
|
||
"[bold cyan]♻️ Pilih File Backup untuk Restore[/bold cyan]\n"
|
||
"[dim]Gunakan panah [UP/DOWN] lalu [ENTER], atau ketik nomor backup. [b] kembali.[/dim]",
|
||
box=ROUNDED,
|
||
border_style="cyan",
|
||
)
|
||
)
|
||
|
||
table = Table(box=ROUNDED, expand=True)
|
||
table.add_column("No", justify="right", style="cyan", width=4)
|
||
table.add_column("File Backup", style="bold white")
|
||
table.add_column("Ukuran", justify="right", style="yellow", width=10)
|
||
table.add_column("Waktu Dibuat", justify="center", style="dim", width=22)
|
||
|
||
for i, b in enumerate(backups):
|
||
size_kb = b.stat().st_size / 1024
|
||
size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb/1024:.2f} MB"
|
||
mtime = datetime.fromtimestamp(b.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
prefix = "▶ " if i == current_idx else " "
|
||
name_text = f"{prefix}{b.name}"
|
||
if i == current_idx:
|
||
row_style = "bold cyan on grey23"
|
||
else:
|
||
row_style = ""
|
||
|
||
table.add_row(
|
||
str(i + 1),
|
||
name_text,
|
||
size_str,
|
||
mtime,
|
||
style=row_style,
|
||
)
|
||
|
||
console.print(table)
|
||
console.print("\n[dim]Pilihan: [ENTER] Restore file terpilih │ [b/ESC] Batal & Kembali[/dim]")
|
||
|
||
k = read_key()
|
||
if k in ("UP", "k"):
|
||
current_idx = (current_idx - 1) % len(backups)
|
||
elif k in ("DOWN", "j"):
|
||
current_idx = (current_idx + 1) % len(backups)
|
||
elif k in ("b", "B", "ESC", "q"):
|
||
return 0
|
||
elif k.isdigit():
|
||
idx = int(k) - 1
|
||
if 0 <= idx < len(backups):
|
||
target = backups[idx]
|
||
run_action_screen(
|
||
f"Restore Backup {target.name}",
|
||
lambda: cmd_dbrestore(backup_path=target, verbose=is_verbose()),
|
||
f"dbrestore {target.name}",
|
||
)
|
||
return 0
|
||
elif k == "ENTER":
|
||
target = backups[current_idx]
|
||
run_action_screen(
|
||
f"Restore Backup {target.name}",
|
||
lambda: cmd_dbrestore(backup_path=target, verbose=is_verbose()),
|
||
f"dbrestore {target.name}",
|
||
)
|
||
return 0
|
||
|
||
|
||
# ─── Generic Interactive Menu Selector ───────────────────────────────────────
|
||
|
||
class MenuItem:
|
||
def __init__(
|
||
self,
|
||
key: str,
|
||
label: str,
|
||
desc: str,
|
||
action: Optional[Callable[[], Any]] = None,
|
||
is_submenu: bool = False,
|
||
):
|
||
self.key = key
|
||
self.label = label
|
||
self.desc = desc
|
||
self.action = action
|
||
self.is_submenu = is_submenu
|
||
|
||
|
||
def run_menu(
|
||
title: str,
|
||
items: List[MenuItem],
|
||
is_main_menu: bool = False,
|
||
) -> None:
|
||
"""Run an interactive arrow-key and numbered menu."""
|
||
selected_idx = 0
|
||
|
||
while True:
|
||
services_status = get_services_status() if is_main_menu else None
|
||
console.clear()
|
||
render_header(services_status)
|
||
|
||
menu_panel_title = f"[bold cyan]{title}[/bold cyan]"
|
||
console.print(
|
||
Panel(
|
||
f"{menu_panel_title}\n"
|
||
f"[dim]Navigasi: [↑/↓] Panah, [ENTER] Pilih, [1-{len(items)}] Tombol Angka, "
|
||
f"[{'q: Keluar' if is_main_menu else 'b: Kembali'}][/dim]",
|
||
box=ROUNDED,
|
||
border_style="cyan",
|
||
)
|
||
)
|
||
|
||
# Menu List
|
||
max_label_len = max(len(it.label) for it in items) if items else 30
|
||
max_label_len = max(max_label_len, 32)
|
||
|
||
for idx, item in enumerate(items):
|
||
is_selected = idx == selected_idx
|
||
item_text = Text()
|
||
if is_selected:
|
||
item_text.append(" ▶ ", style="bold cyan")
|
||
item_text.append(f"[{item.key}] ", style="bold yellow on grey23")
|
||
item_text.append(f"{item.label:<{max_label_len + 2}}", style="bold white on grey23")
|
||
else:
|
||
item_text.append(" ", style="dim")
|
||
item_text.append(f"[{item.key}] ", style="bold yellow")
|
||
item_text.append(f"{item.label}", style="white")
|
||
console.print(item_text)
|
||
|
||
console.print()
|
||
|
||
# Focused Menu Item Description Panel
|
||
if items:
|
||
active_item = items[selected_idx]
|
||
desc_text = active_item.desc or "Tidak ada deskripsi."
|
||
if active_item.is_submenu:
|
||
action_hint = "Tekan [ENTER] atau nomor menu untuk membuka submenu"
|
||
elif active_item.key.lower() == "t":
|
||
action_hint = "Tekan [ENTER] atau [t] untuk toggle"
|
||
elif active_item.key.lower() == "q":
|
||
action_hint = "Tekan [ENTER] atau [q] untuk keluar"
|
||
elif active_item.key.lower() == "b":
|
||
action_hint = "Tekan [ENTER] atau [b] untuk kembali"
|
||
else:
|
||
action_hint = "Tekan [ENTER] untuk mengeksekusi"
|
||
|
||
desc_panel = Panel(
|
||
f" [white]{desc_text}[/white]",
|
||
title=f"[bold cyan]ℹ️ Deskripsi: [white]{active_item.label}[/white][/bold cyan]",
|
||
title_align="left",
|
||
subtitle=f"[dim]{action_hint}[/dim]",
|
||
subtitle_align="right",
|
||
box=ROUNDED,
|
||
border_style="blue",
|
||
padding=(0, 1),
|
||
)
|
||
console.print(desc_panel)
|
||
|
||
try:
|
||
k = read_key()
|
||
except KeyboardInterrupt:
|
||
if is_main_menu:
|
||
console.print("\nSampai jumpa! 👋")
|
||
sys.exit(0)
|
||
else:
|
||
return
|
||
|
||
if k in ("UP", "k"):
|
||
selected_idx = (selected_idx - 1) % len(items)
|
||
elif k in ("DOWN", "j"):
|
||
selected_idx = (selected_idx + 1) % len(items)
|
||
elif k in ("t", "T"):
|
||
# If an item specifically has key 't', execute it; otherwise toggle verbose mode
|
||
has_t_item = False
|
||
for idx, item in enumerate(items):
|
||
if item.key.lower() == "t":
|
||
selected_idx = idx
|
||
if item.action:
|
||
item.action()
|
||
has_t_item = True
|
||
break
|
||
if not has_t_item:
|
||
toggle_verbose()
|
||
elif k in ("q", "Q") and is_main_menu:
|
||
console.print("\nSampai jumpa! 👋")
|
||
sys.exit(0)
|
||
elif k in ("b", "B", "ESC", "q", "Q") and not is_main_menu:
|
||
return
|
||
elif k == "ENTER":
|
||
chosen = items[selected_idx]
|
||
if chosen.key.lower() == "b" or (not is_main_menu and chosen.action is None):
|
||
return
|
||
elif chosen.key.lower() == "q" and is_main_menu:
|
||
console.print("\nSampai jumpa! 👋")
|
||
sys.exit(0)
|
||
elif chosen.action:
|
||
chosen.action()
|
||
if not chosen.is_submenu and not is_main_menu:
|
||
# After running an action in submenu, stay in submenu
|
||
pass
|
||
else:
|
||
# Check for direct key match
|
||
for idx, item in enumerate(items):
|
||
if k.lower() == item.key.lower():
|
||
selected_idx = idx
|
||
if item.key.lower() == "b" and not is_main_menu:
|
||
return
|
||
elif item.key.lower() == "q" and is_main_menu:
|
||
console.print("\nSampai jumpa! 👋")
|
||
sys.exit(0)
|
||
elif item.action:
|
||
item.action()
|
||
elif not is_main_menu and item.action is None:
|
||
return
|
||
break
|
||
|
||
|
||
# ─── Menu Definitions ────────────────────────────────────────────────────────
|
||
|
||
def menu_server_lifecycle() -> None:
|
||
items = [
|
||
MenuItem(
|
||
"1",
|
||
"Run (Start)",
|
||
"Menjalankan container yang sudah ada (up -d)",
|
||
lambda: run_action_screen("Start LMS Containers", lambda: cmd_run(verbose=is_verbose()), "run"),
|
||
),
|
||
MenuItem(
|
||
"2",
|
||
"Run + Build",
|
||
"Build container image lalu jalankan",
|
||
lambda: run_action_screen("Build and Run Containers", lambda: cmd_runbuild(verbose=is_verbose()), "runbuild"),
|
||
),
|
||
MenuItem(
|
||
"3",
|
||
"Run + Clean Rebuild",
|
||
"Prune cache, rebuild total no-cache & jalankan",
|
||
lambda: run_action_screen(
|
||
"Clean Rebuild and Run", lambda: cmd_runclearbuild(verbose=is_verbose()), "runclearbuild"
|
||
),
|
||
),
|
||
MenuItem(
|
||
"4",
|
||
"Stop Containers",
|
||
"Hentikan semua container yang sedang berjalan",
|
||
lambda: run_action_screen("Stop Containers", lambda: cmd_stop(verbose=is_verbose()), "stop"),
|
||
),
|
||
MenuItem(
|
||
"5",
|
||
"Stop + Clean (Prune)",
|
||
"Hentikan container & bersihkan stopped containers serta image tak terpakai",
|
||
lambda: run_action_screen("Stop and Clean", lambda: cmd_stopclean(verbose=is_verbose()), "stopclean"),
|
||
),
|
||
MenuItem(
|
||
"6",
|
||
"Verify Images & Containers",
|
||
"Verifikasi status container, image tag, dan STALE status",
|
||
lambda: run_action_screen("Verify Containers", lambda: cmd_verify(verbose=is_verbose()), "verify"),
|
||
),
|
||
MenuItem(
|
||
"t",
|
||
"Toggle Verbose Mode",
|
||
"Ubah mode log detail (ON / OFF)",
|
||
lambda: (toggle_verbose(), None)[1],
|
||
),
|
||
MenuItem("b", "Kembali", "Kembali ke menu utama", None),
|
||
]
|
||
run_menu("🚀 Server & Container Lifecycle", items)
|
||
|
||
|
||
def menu_database_users() -> None:
|
||
items = [
|
||
MenuItem(
|
||
"1",
|
||
"Migrasi Database (Upgrade)",
|
||
"Jalankan alembic upgrade head",
|
||
lambda: run_action_screen("Database Migration", lambda: cmd_dbupgrade(verbose=is_verbose()), "dbupgrade"),
|
||
),
|
||
MenuItem(
|
||
"2",
|
||
"Status Versi Schema",
|
||
"Cek alembic current & heads",
|
||
lambda: run_action_screen("Database Version Status", lambda: cmd_dbstatus(verbose=is_verbose()), "dbstatus"),
|
||
),
|
||
MenuItem(
|
||
"3",
|
||
"Setup / Update Akun Guru",
|
||
"Prompt nama & token guru untuk upsert satu akun",
|
||
lambda: run_action_screen("Setup Akun Guru", lambda: cmd_teacher(interactive=True, verbose=is_verbose()), "teacher"),
|
||
),
|
||
MenuItem(
|
||
"4",
|
||
"Backup Database",
|
||
"Dump PostgreSQL ke backups/elemes_<timestamp>.sql",
|
||
lambda: run_action_screen(
|
||
"Backup Database", lambda: cmd_dbbackup(verbose=is_verbose()), "dbbackup"
|
||
),
|
||
),
|
||
MenuItem(
|
||
"5",
|
||
"Restore Database (Pilih File)",
|
||
"Pilih file backup dari daftar untuk di-restore",
|
||
interactive_restore_screen,
|
||
),
|
||
MenuItem("b", "Kembali", "Kembali ke menu utama", None),
|
||
]
|
||
run_menu("🗄️ Database & User Management", items)
|
||
|
||
|
||
def menu_test_suite() -> None:
|
||
items = [
|
||
MenuItem(
|
||
"1",
|
||
"Unit Test",
|
||
"Unit test cepat tanpa database (pytest -m unit)",
|
||
lambda: run_action_screen("Unit Test Suite", lambda: cmd_test_unit(verbose=is_verbose()), "test-unit"),
|
||
),
|
||
MenuItem(
|
||
"2",
|
||
"Integration Test",
|
||
"Integration test (memerlukan PostgreSQL)",
|
||
lambda: run_action_screen(
|
||
"Integration Test Suite", lambda: cmd_test_integration(verbose=is_verbose()), "test-integration"
|
||
),
|
||
),
|
||
MenuItem(
|
||
"3",
|
||
"Full Test Suite",
|
||
"Jalankan semua test suite (CI gate)",
|
||
lambda: run_action_screen("Full Test Suite", lambda: cmd_test_all(verbose=is_verbose()), "test-all"),
|
||
),
|
||
MenuItem(
|
||
"4",
|
||
"Smoke Test Post-Deploy",
|
||
"Unit test + subset tes sub-home pasca deploy",
|
||
lambda: run_action_screen("Smoke Test", lambda: cmd_test_smoke(verbose=is_verbose()), "test-smoke"),
|
||
),
|
||
MenuItem(
|
||
"5",
|
||
"Compiler Worker Test",
|
||
"Jalankan compiler worker test suite",
|
||
lambda: run_action_screen(
|
||
"Compiler Worker Tests", lambda: cmd_test_worker(verbose=is_verbose()), "test-worker"
|
||
),
|
||
),
|
||
MenuItem(
|
||
"6",
|
||
"Validasi Dokumentasi",
|
||
"Cek broken link & frontmatter materi di docs/",
|
||
lambda: run_action_screen(
|
||
"Docs Validation", lambda: cmd_docs_validate(verbose=is_verbose()), "docs-validate"
|
||
),
|
||
),
|
||
MenuItem("b", "Kembali", "Kembali ke menu utama", None),
|
||
]
|
||
run_menu("🧪 Test Suite & Quality Checks", items)
|
||
|
||
|
||
def menu_velxio() -> None:
|
||
items = [
|
||
MenuItem(
|
||
"1",
|
||
"Run Velxio Standalone",
|
||
"Jalankan simulator Velxio di background",
|
||
lambda: run_action_screen("Run Velxio Standalone", lambda: cmd_velxio_run(verbose=is_verbose()), "velxio-run"),
|
||
),
|
||
MenuItem(
|
||
"2",
|
||
"Build & Run Velxio",
|
||
"Build image lalu jalankan Velxio standalone",
|
||
lambda: run_action_screen(
|
||
"Build & Run Velxio", lambda: cmd_velxio_runbuild(verbose=is_verbose()), "velxio-runbuild"
|
||
),
|
||
),
|
||
MenuItem(
|
||
"3",
|
||
"Stop Velxio Standalone",
|
||
"Hentikan simulator Velxio standalone",
|
||
lambda: run_action_screen("Stop Velxio", lambda: cmd_velxio_stop(verbose=is_verbose()), "velxio-stop"),
|
||
),
|
||
MenuItem(
|
||
"4",
|
||
"Status & Health Check",
|
||
"Cek mode (local/remote) dan health check endpoint",
|
||
lambda: run_action_screen("Velxio Status", cmd_velxio_status, "velxio-status"),
|
||
),
|
||
MenuItem(
|
||
"5",
|
||
"Frontend Vitest",
|
||
"Jalankan unit test frontend modul Velxio",
|
||
lambda: run_action_screen("Velxio Frontend Vitest", lambda: cmd_velxio_test(), "velxio-test"),
|
||
),
|
||
MenuItem("b", "Kembali", "Kembali ke menu utama", None),
|
||
]
|
||
run_menu("📡 Velxio Simulator Standalone", items)
|
||
|
||
|
||
def menu_deploy_export() -> None:
|
||
items = [
|
||
MenuItem(
|
||
"1",
|
||
"Export Precompiled Bundle (TAR)",
|
||
"Build backend, frontend, velxio -> lms-precompiled.tar",
|
||
lambda: run_action_screen(
|
||
"Export Precompiled Bundle", lambda: cmd_exportall(verbose=is_verbose()), "exportall"
|
||
),
|
||
),
|
||
MenuItem(
|
||
"2",
|
||
"Import Precompiled Bundle",
|
||
"Load image dari lms-precompiled.tar untuk deployment VPS",
|
||
lambda: run_action_screen(
|
||
"Import Precompiled Bundle", lambda: cmd_importall(verbose=is_verbose()), "importall"
|
||
),
|
||
),
|
||
MenuItem("b", "Kembali", "Kembali ke menu utama", None),
|
||
]
|
||
run_menu("📦 Deploy & Precompiled Bundles", items)
|
||
|
||
|
||
def menu_setup_env() -> None:
|
||
def show_env_info() -> int:
|
||
env = read_env()
|
||
table = Table(title="Variabel Environment (.env)", box=ROUNDED)
|
||
table.add_column("Kunci (Key)", style="bold cyan")
|
||
table.add_column("Nilai (Value)", style="white")
|
||
|
||
sensitive_keys = {"PASSWORD", "SECRET", "TOKEN", "KEY"}
|
||
for k, v in sorted(env.items()):
|
||
is_sensitive = any(s in k.upper() for s in sensitive_keys)
|
||
display_val = "••••••••" if is_sensitive and v else v
|
||
table.add_row(k, display_val)
|
||
|
||
console.print(table)
|
||
return 0
|
||
|
||
items = [
|
||
MenuItem(
|
||
"1",
|
||
"Inisialisasi Proyek (Quick Start)",
|
||
"Buat folder .env, content, assets, state dari template",
|
||
lambda: run_action_screen("Quick Start Init", lambda: cmd_init(verbose=is_verbose()), "init"),
|
||
),
|
||
MenuItem(
|
||
"2",
|
||
"Generate Config Tailscale Serve",
|
||
"Generate config/lms-tail.json sesuai VELXIO_MODE",
|
||
lambda: run_action_screen(
|
||
"Generate Tailscale Config",
|
||
lambda: (generate_ts_config(), print("✅ lms-tail.json berhasil di-generate."), 0)[2],
|
||
"ts-config",
|
||
),
|
||
),
|
||
MenuItem(
|
||
"3",
|
||
"Inspeksi Konfigurasi .env",
|
||
"Tampilkan konfigurasi aktif dengan masking data sensitif",
|
||
lambda: run_action_screen("Inspeksi .env", show_env_info, "env-info"),
|
||
),
|
||
MenuItem(
|
||
"t",
|
||
"Toggle Verbose Mode",
|
||
"Aktifkan / nonaktifkan mode log detail (verbose)",
|
||
lambda: (toggle_verbose(), None)[1],
|
||
),
|
||
MenuItem("b", "Kembali", "Kembali ke menu utama", None),
|
||
]
|
||
run_menu("⚙️ Setup & Environment", items)
|
||
|
||
|
||
# ─── Main TUI Entrypoint ─────────────────────────────────────────────────────
|
||
|
||
def launch_tui() -> None:
|
||
"""Launch the modern interactive TUI dashboard and menu."""
|
||
main_items = [
|
||
MenuItem(
|
||
"1",
|
||
"Server & Containers",
|
||
"Start, stop, build, dan verifikasi kontainer LMS",
|
||
menu_server_lifecycle,
|
||
is_submenu=True,
|
||
),
|
||
MenuItem(
|
||
"2",
|
||
"Database & Users",
|
||
"Migrasi alembic, akun guru, backup & restore database",
|
||
menu_database_users,
|
||
is_submenu=True,
|
||
),
|
||
MenuItem(
|
||
"3",
|
||
"Test Suite & Quality",
|
||
"Unit test, integration test, smoke test, compiler test",
|
||
menu_test_suite,
|
||
is_submenu=True,
|
||
),
|
||
MenuItem(
|
||
"4",
|
||
"Velxio Simulator",
|
||
"Kontrol simulator modular Velxio standalone",
|
||
menu_velxio,
|
||
is_submenu=True,
|
||
),
|
||
MenuItem(
|
||
"5",
|
||
"Export & Deploy",
|
||
"Build & pack bundle image TAR siap upload ke VPS",
|
||
menu_deploy_export,
|
||
is_submenu=True,
|
||
),
|
||
MenuItem(
|
||
"6",
|
||
"Setup & Environment",
|
||
"Inisialisasi template folder, Tailscale serve, inspeksi .env",
|
||
menu_setup_env,
|
||
is_submenu=True,
|
||
),
|
||
MenuItem(
|
||
"v",
|
||
"Quick Verify",
|
||
"Cek cepat image status & kontainer yang berjalan",
|
||
lambda: run_action_screen("Quick Verify", lambda: cmd_verify(verbose=is_verbose()), "verify"),
|
||
),
|
||
MenuItem(
|
||
"t",
|
||
"Toggle Verbose Mode",
|
||
"Ubah mode log detail (ON / OFF)",
|
||
lambda: (toggle_verbose(), None)[1],
|
||
),
|
||
MenuItem("q", "Keluar", "Tutup aplikasi Elemes TUI", None),
|
||
]
|
||
|
||
try:
|
||
run_menu("Menu Utama Elemes LMS", main_items, is_main_menu=True)
|
||
except KeyboardInterrupt:
|
||
console.print("\nSampai jumpa! 👋")
|