"""Core interactive TUI session class."""
from __future__ import annotations
import os
import threading
import time
from lib import constant as C
from .actions import _DeployActionsMixin
from .state import PodProgressState
from .terminal import TerminalContext
from .tui_utils import draw_box, format_progress_bar
class DeployInteractiveSession(_DeployActionsMixin):
"""Post-deployment interactive TUI.
Manages the terminal UI lifecycle: terminal raw-mode switching,
main menu rendering with inline progress bars, keyboard navigation
(arrow keys + vim-style h/j/k/l), and status/feedback display.
"""
_MENU_DEFS: list[tuple] = []
def __init__(
self,
namespace: str,
pod_cnt: int,
user_config: dict,
log_running: bool = False,
deployed: bool = True,
):
self.namespace = namespace
self.pod_cnt = pod_cnt
self.user_config = user_config
self.deployer_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
self._running = False
self._log_running = log_running
self._deployed = deployed
self._cancel_event = threading.Event()
self._monitor_threads: list[threading.Thread] = []
self._pod_progress = PodProgressState()
self._status_msg: str | None = None
self._status_expiry: float = 0.0
self._confirm_action: str | None = None
self._confirm_expiry: float = 0.0
self._menu_selected: int = 0
self._flash_item: int | None = None
self._flash_expiry: float = 0.0
self._ctx: TerminalContext | None = None
self._width: int = C.MIN_BOX_WIDTH
self._last_box_end_row: int = 10
self._progress_selected: int = 0
self._progress_active: bool = False
self._deploy_log_lines: list[str] = []
self._last_config_dir: str = ""
self._in_delete_countdown: bool = False
self._pod_status: dict[str, str] = {}
self._pod_status_lock = threading.Lock()
self._pod_watcher_stop = threading.Event()
self._pod_watcher_thread: threading.Thread | None = None
@property
def width(self) -> int:
return self._width
def run(self) -> None:
"""Enter interactive session (blocks until user exits)."""
with TerminalContext() as ctx:
self._ctx = ctx
self._width = ctx.width
self._running = True
while self._running:
self._menu_main()
self._cleanup()
def _set_status(self, msg: str, duration: float = C.STATUS_DURATION) -> None:
self._status_msg = msg
self._status_expiry = time.monotonic() + duration
self._flush_status()
def _set_confirm(self, action: str) -> None:
self._confirm_action = action
self._confirm_expiry = time.monotonic() + C.CONFIRM_DURATION
def _clear_confirm(self) -> None:
self._confirm_action = None
self._confirm_expiry = 0.0
def _flush_status(self) -> None:
"""Immediately write the current status line to the terminal."""
if self._ctx is None:
return
row = getattr(self, '_last_box_end_row', 10)
status = self._build_status_line(row)
if status:
self._ctx._write(f'\033[{row + 1};1H{status}')
else:
self._ctx._write(f'\033[{row + 1};1H\033[K')
def _render_status_line(self, box_end_row: int) -> None:
"""Render the status / feedback line below the box."""
if self._ctx is None:
return
self._last_box_end_row = box_end_row
status = self._build_status_line(box_end_row)
if status:
self._ctx._write(f'\033[{box_end_row + 1};1H{status}')
else:
self._ctx._write(f'\033[{box_end_row + 1};1H\033[K')
def _read_key(self, timeout: float = 0.08) -> str | None:
"""Read a single keystroke. Arrow keys and vim-style ``j/k`` are
normalised to ``'UP'`` / ``'DOWN'``.
Returns ``None`` on timeout.
"""
ctx = self._ctx
if ctx is None:
return None
key = ctx.poll_key(timeout=timeout)
if key is None:
return None
if key == 'j':
return 'DOWN'
if key == 'k':
return 'UP'
if key != '\033':
return key
seq = ''
for _ in range(5):
nxt = ctx.poll_key(timeout=0.03)
if nxt is None:
break
seq += nxt
if seq == '[A':
return 'UP'
if seq == '[B':
return 'DOWN'
if seq == '[C':
return 'RIGHT'
if seq == '[D':
return 'LEFT'
return '\033'
def _menu_main(self) -> None:
"""Render the main menu — adapts to deployed / undeployed state."""
ctx = self._ctx
ctx.flush_input()
self._clear_confirm()
self._status_msg = None
self._width = ctx.width
self._menu_selected = 0
self._flash_item = None
self._flash_expiry = 0.0
_repoll_deadline = 0.0
_progress_retry_deadline = 0.0
while self._running:
now = time.monotonic()
if self._deployed and self._pod_watcher_thread is None:
self._start_pod_watcher()
dep_cfg = self.user_config.get(C.MOTOR_DEPLOY_CONFIG, {})
is_single = dep_cfg.get(C.DEPLOY_MODE_CONFIG_KEY, "") == C.DEPLOY_MODE_SINGLE_CONTAINER
if (
self._deployed
and not self._progress_active
and not is_single
and self.pod_cnt > 0
and now >= _progress_retry_deadline
):
self._start_progress_monitor()
_progress_retry_deadline = now + 5
if self._progress_active and hasattr(self, '_progress_monitor') and now >= _repoll_deadline:
self._rescan_pods()
_repoll_deadline = now + 5
if self._deployed:
log_status = (
f"{C.Style.GREEN}● Running{C.Style.RESET}"
if self._log_running
else f"{C.Style.DIM}○ Stopped{C.Style.RESET}"
)
log_action = "Restart log collection" if self._log_running else "Start log collection"
if is_single:
total_pods = self.pod_cnt
pod_info = f"{total_pods} (single-container x{total_pods})"
else:
standby = dep_cfg.get(C.STANDBY_CONFIG, {}).get(C.ENABLE_MASTER_STANDBY)
coord_cnt = 2 if standby else 1
total_pods = self.pod_cnt + 1 + coord_cnt
pod_info = f"{total_pods} (controller:1 coordinator:{coord_cnt} engine:{self.pod_cnt})"
if is_single:
items = [
('L', C.Style.YELLOW, log_action, f"{C.Style.GRAY}({log_status}){C.Style.RESET}"),
('U', C.Style.BLUE, 'Update config', ''),
('D', C.Style.RED, 'Delete service', ''),
('Q', C.Style.RED, 'Exit', ''),
]
else:
prog_status = (
f"{C.Style.GREEN}● Showing{C.Style.RESET}"
if self._progress_active
else f"{C.Style.DIM}○ Idle{C.Style.RESET}"
)
prog_label = "Close progress" if self._progress_active else "Show startup progress"
items = [
('P', C.Style.GREEN, prog_label, f"{C.Style.GRAY}({prog_status}){C.Style.RESET}"),
('L', C.Style.YELLOW, log_action, f"{C.Style.GRAY}({log_status}){C.Style.RESET}"),
('U', C.Style.BLUE, 'Update config', ''),
('D', C.Style.RED, 'Delete service', ''),
('Q', C.Style.RED, 'Exit', ''),
]
footer_hint = (
"j/k (↑↓) to navigate Enter to select or press [L] [U] [D] [Q]"
if is_single
else "j/k (↑↓) to navigate Enter to select or press [P] [L] [U] [D] [Q]"
)
header = [
"",
f" Namespace: {C.Style.BOLD}{self.namespace}{C.Style.RESET}",
f" Pods expected: {C.Style.BOLD}{pod_info}{C.Style.RESET}",
f" Log collector: {log_status}",
"",
]
else:
items = [
('R', C.Style.GREEN, 'Deploy services', ''),
('Q', C.Style.RED, 'Exit', ''),
]
header = [
"",
f" {C.Style.DIM}No services deployed.{C.Style.RESET}",
f" {C.Style.DIM}Use [R] to deploy from a config directory.{C.Style.RESET}",
"",
]
footer_hint = "j/k (↑↓) to navigate Enter to select or press [R] [Q]"
n_items = len(items)
def _item_line(idx: int, key: str, color: str, label: str, extra: str = "") -> str:
is_sel = idx == self._menu_selected
is_flash = idx == self._flash_item
if is_flash:
cursor = f"{C.Style.BLINK}{C.Style.REVERSE} ▶ {C.Style.RESET}"
kpart = f"{C.Style.REVERSE}{C.Style.BOLD}{color}{C.Style.BLINK}[{key}]{C.Style.RESET}"
text = f"{C.Style.REVERSE}{C.Style.BOLD} {label} {extra} {C.Style.RESET}"
elif is_sel:
cursor = f"{C.Style.BLINK}{C.Style.CYAN}▶{C.Style.RESET} "
kpart = f"{C.Style.BOLD}{color}[{key}]{C.Style.RESET}"
text = f" {label} {extra}"
else:
cursor = " "
kpart = f"{C.Style.BOLD}{color}[{key}]{C.Style.RESET}"
text = f" {label} {extra}"
return f"{cursor}{kpart}{text}"
body = list(header)
if self._deployed:
with self._pod_status_lock:
status_snapshot = dict(self._pod_status)
if status_snapshot:
max_name = max((len(n) for n in status_snapshot), default=30)
pad_w = min(max_name + 2, 55)
body.append("")
body.append(f" {C.Style.BOLD}Pod Status:{C.Style.RESET}")
body.append(
f" {'NAME':^{pad_w}}{'READY':>5s} {'STATUS':<10s}"
f" {'RESTARTS':>8s} {'AGE':>5s} {'POD IP':<15s} NODE"
)
for pod_name in sorted(status_snapshot):
info = status_snapshot[pod_name]
display_name = pod_name[:pad_w].ljust(pad_w)
body.append(f" {C.Style.DIM}{display_name}{C.Style.RESET}{info}")
body.append("")
if self._deployed and self._progress_active:
pods = self._pod_progress.get_all()
if pods:
static_overhead = 45
usable = self.width - 4 - static_overhead
max_name = max((len(n) for n in pods.keys()), default=20)
label_w = min(max_name, max(10, usable - 10), 25)
bar_w = max(8, usable - label_w)
for pod_name in sorted(pods.keys()):
ps = pods[pod_name]
line = format_progress_bar(
ps["progress"],
pod_name[:label_w].ljust(label_w),
bar_w,
line_cnt=ps["line_index"],
err_cnt=ps["error_count"],
elapsed=now - ps.get("started_at", now),
)
body.append(line)
else:
body.append(f" {C.Style.DIM}Waiting for pods...{C.Style.RESET}")
body.append("")
for i, (dk, col, lbl, xtra) in enumerate(items):
body.append(_item_line(i, dk, col, lbl, xtra))
body.append("")
footer = [
f" {C.Style.DIM}{footer_hint}{C.Style.RESET}",
]
lines = draw_box("Deployer", body, self.width, footer_lines=footer, double_border=True)
self._render_screen(lines)
if self._flash_item is not None:
if now >= self._flash_expiry:
action_idx = self._flash_item
self._flash_item = None
self._execute_menu_action(action_idx, items)
else:
time.sleep(0.06)
continue
key = self._read_key(timeout=0.08)
if key is None:
continue
if key == 'UP':
self._menu_selected = (self._menu_selected - 1) % n_items
elif key == 'DOWN':
self._menu_selected = (self._menu_selected + 1) % n_items
elif key == '\r':
self._flash_item = self._menu_selected
self._flash_expiry = time.monotonic() + C.FLASH_DURATION
elif key == '\033':
if self._confirm_action:
self._clear_confirm()
else:
matched = False
for i, (dk, _col, _lbl, _xtra) in enumerate(items):
if key.lower() == dk.lower():
self._flash_item = i
self._flash_expiry = time.monotonic() + C.FLASH_DURATION
matched = True
break
if not matched and self._confirm_action:
self._clear_confirm()
def _execute_menu_action(self, idx: int, items: list[tuple]) -> None:
"""Execute the action for menu item *idx*, passing its direct-key."""
dk = items[idx][0]
if self._deployed:
mapping = {
'P': self._toggle_progress,
'L': self._handle_log_key,
'U': self._update_config,
'D': self._delete_service,
'Q': lambda: setattr(self, '_running', False),
}
else:
mapping = {
'R': self._deploy_service,
'Q': lambda: setattr(self, '_running', False),
}
action = mapping.get(dk)
if action is None:
return
self._deploy_log_lines = []
if self._ctx is not None:
log_start = self._last_box_end_row + 2
self._ctx._write(f"\033[{log_start};1H\033[J")
key_label = f"{C.Style.BOLD}{C.Style.CYAN}[{dk}]{C.Style.RESET}"
if dk == 'Q':
self._set_status(f"{key_label} → {C.Style.RED}Exiting...{C.Style.RESET}", 0.5)
time.sleep(0.05)
elif dk == 'P':
label = "Closed" if self._progress_active else "Opened"
self._set_status(f"{key_label} → {C.Style.GREEN}Progress {label}{C.Style.RESET}", 1.5)
elif dk == 'R':
self._set_status(f"{key_label} → {C.Style.GREEN}Deploy services...{C.Style.RESET}", 1.5)
action()
if dk not in ('Q', 'R') and self._ctx is not None:
self._ctx.clear()
def _render_screen(self, lines: list[str]) -> None:
"""Write *lines* to terminal in a single batch, plus deploy logs."""
ctx = self._ctx
parts = []
for i, line in enumerate(lines):
parts.append(f'\033[{i + 1};1H{line}')
box_end = len(lines)
status = self._build_status_line(box_end)
if status:
parts.append(f'\033[{box_end + 1};1H{status}')
else:
parts.append(f'\033[{box_end + 1};1H\033[K')
log_start = box_end + 2
if self._deploy_log_lines:
max_w = self.width - 4
row = log_start
for log_line in self._deploy_log_lines:
while len(log_line) > max_w:
parts.append(f"\033[{row};1H {C.Style.DIM}{log_line[:max_w]}{C.Style.RESET}\033[K")
log_line = log_line[max_w:]
row += 1
if row >= log_start + 30:
break
if row >= log_start + 30:
break
parts.append(f"\033[{row};1H {C.Style.DIM}{log_line}{C.Style.RESET}\033[K")
row += 1
else:
parts.append(f'\033[{log_start};1H\033[J')
parts.append(f'\033[{box_end + 3};1H')
ctx._write(''.join(parts))
def _build_status_line(self, box_end_row: int) -> str:
"""Return the status-line string (without cursor positioning)."""
if self._ctx is None:
return ''
self._last_box_end_row = box_end_row
now = time.monotonic()
if self._status_msg and now >= self._status_expiry:
self._status_msg = None
if self._confirm_action and now >= self._confirm_expiry:
self._clear_confirm()
self._set_status(f"{C.Style.DIM}Confirmation timed out — cancelled.{C.Style.RESET}", 1.2)
return self._build_status_line(box_end_row)
if self._confirm_action:
action_label = {
'log_restart': 'restart log collection',
'log_stop': 'stop log collection',
}.get(self._confirm_action, self._confirm_action)
remaining = max(0, self._confirm_expiry - now)
return (
f" {C.Style.BOLD}{C.Style.ORANGE}⚠ Confirm:{C.Style.RESET} "
f"Log collector is already running. "
f"Press {C.Style.BOLD}{C.Style.YELLOW}[L]{C.Style.RESET} again to "
f"{C.Style.BOLD}{action_label}{C.Style.RESET} "
f"({C.Style.DIM}{remaining:.0f}s{C.Style.RESET}) — "
f"{C.Style.DIM}any other key cancels{C.Style.RESET}\033[K"
)
elif self._status_msg:
return f" {self._status_msg}\033[K"
return ''
def _cleanup_monitors(self) -> None:
self._cancel_event.set()
for t in self._monitor_threads:
t.join(timeout=3)
self._monitor_threads.clear()
def _cleanup(self) -> None:
if self._progress_active:
self._stop_progress_monitor()
self._stop_pod_watcher()
if self._log_running:
self._stop_log_collection()