prole/installer/ui/screens/network.py
chrisfu ec50f82cc8 refactor: deduplicate installer business logic & split screens.py into package
Separation of concerns: merge silent/UI actions & modularize screens.

ProleInstallerBase (actions.py): Created shared base class with 37 deduplicated methods previously duplicated between ProleSilentInstaller and ProleInstaller. Namespace, environment, secret, deployment, port-forward, authority/repair, image, and logging helpers now defined once. Subclasses override _get_input() to bridge their data-access layers.

screens.py -> screens/ package (18 mixin modules): Split 10,234-line monolithic screens.py into focused mixin modules: base, navigation, welcome, dependencies, network, environment, database, cluster, services, security, ollama, supabase, docker, build, packaging, deploy, validate, cfg. __init__.py composes ProleInstaller from all mixins and re-exports has_display(), main() for full backward compatibility.

All 37 tests pass with no regressions.
2026-02-20 14:43:20 -08:00

242 lines
11 KiB
Python

"""Network scan screen."""
import os
import socket
import subprocess
import threading
import time
from pathlib import Path
import tkinter as tk
from installer import screen as ui
from installer.core.env import get_resource_path
class NetworkScreenMixin:
"""Network scan screen."""
def _render_network_scan_page(self):
# Letterhead at top right (matching welcome screen theme)
content_width = self.bg_canvas.winfo_width() or 975
right_margin = content_width - 48
ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne')
ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73',
font=('SF Pro Text', 18), anchor='ne')
self._render_title('Network Configuration Scan', y=150)
self._render_paragraph("The network prole-agent will detect Kerberos services, Active Directory controllers, and other configuration details required for deployment.", y=210)
y = 280
self.scan_status_var = tk.StringVar(value="Ready to prole-agent")
# Ready to scan text (drawn on canvas)
status_item = ui.canvas_text(self, 48, y, "Ready to prole-agent", fill='black', font=('SF Pro Text', 12))
# Link variable to canvas text
def update_status_text(*args):
try:
self.bg_canvas.itemconfig(status_item, text=self.scan_status_var.get())
except Exception:
pass
self.scan_status_var.trace_add('write', update_status_text)
# Standardized Console Output
self.scan_results_console = self._create_console_output(y=320, title="Scan Output", width=880, height=380)
self.scan_results_text = self.scan_results_console.text
if getattr(self, 'ansible_topology_summary', ''):
self.scan_results_console.write(self.ansible_topology_summary + "\n")
self.scan_results_console.write("Ansible topology loaded; network scan can refine detection.\n")
y = 740
# Start Network Scan Button (placed on canvas)
self.scan_btn = tk.Button(self.bg_canvas, text="Start Network Scan", command=self._run_network_scan,
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
highlightbackground='#F5F5DC', highlightthickness=0,
relief='flat', font=('SF Pro Text', 11), padx=20, pady=10)
self.btn_window = self.bg_canvas.create_window(48, y, window=self.scan_btn, anchor='nw')
self._overlay_widgets.append(self.scan_btn)
self._canvas_items.append(self.btn_window)
# Prepare animation frames
if not GLOBAL_SCAN_FRAMES:
try:
from PIL import Image, ImageTk
gif_path = get_resource_path('img/prole-type.gif')
if gif_path.exists():
gif = Image.open(str(gif_path))
max_frames = 120
try:
while len(GLOBAL_SCAN_FRAMES) < max_frames:
# Create a copy and resize
frame = gif.copy().convert('RGBA')
frame.thumbnail((24, 24), Image.LANCZOS)
GLOBAL_SCAN_FRAMES.append(ImageTk.PhotoImage(frame))
gif.seek(len(GLOBAL_SCAN_FRAMES))
except EOFError:
pass
except Exception as e:
print(f"Error loading scan animation: {e}")
self._scan_frames = GLOBAL_SCAN_FRAMES
y += 60
# Hint about auto-fill
autofill_msg = "Scan results will auto-fill Kerberos and Environment settings."
self._canvas_items.append(ui.canvas_text(self, 48, y, autofill_msg, fill='#6e6e73', font=('SF Pro Text', 10, 'italic')))
def _animate_scan_button(self, frame_idx=0):
if not getattr(self, '_scan_running', False) or not self._scan_frames:
if hasattr(self, 'scan_btn'):
self.scan_btn.config(image='', compound='none')
return
self.scan_btn.config(image=self._scan_frames[frame_idx], compound='left')
next_idx = (frame_idx + 1) % len(self._scan_frames)
self.root.after(100, lambda: self._animate_scan_button(next_idx))
def _run_network_scan(self):
if getattr(self, '_scan_running', False):
return
self._action_flags['network_scan.run'] = True
self._scan_running = True
self._scan_kdc_value = None
ansible_kdc = ''
try:
ansible_kdc = (self.ansible_topology or {}).get('kdc_ip') or ''
if not ansible_kdc:
ansible_kdc = (self.prole_cfg_data.get('Network', {}) or {}).get('KDC_ANSIBLE_DETECTED', '')
except Exception:
ansible_kdc = ''
self.scan_results_console.clear()
self.scan_results_console.write("Initializing network scan using prole-net/prole-agent ...\n")
self.scan_status_var.set("Scanning...")
# Start animation
if self._scan_frames:
self._animate_scan_button()
def worker():
try:
# Use the new scan binary
scan_binary = get_resource_path("prole-net/prole-agent")
if not scan_binary.exists():
self.safe_after(lambda: self.scan_results_console.write(f"Scan binary not found at {scan_binary}\n"))
self.safe_after(lambda: self.scan_status_var.set("Scan failed"))
self._scan_running = False
return
# Create writable scan directory for output/cache
# This avoids issues with PyInstaller's read-only _MEIPASS directory
prole_home = Path.home() / ".prole"
scan_dir = prole_home / "scan"
scan_dir.mkdir(parents=True, exist_ok=True)
# Run scan from writable directory with 10s timeout and summary analysis
# Set bufsize=0 for truly unbuffered binary stream reading
# Ensure PYTHONUNBUFFERED=1 is set in case prole-agent is/uses Python
env = os.environ.copy()
env['PYTHONUNBUFFERED'] = '1'
process = subprocess.Popen([str(scan_binary), "-t", "10"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
env=env,
cwd=str(scan_dir))
# Capture output in real-time using larger reads to avoid overhead
fd = process.stdout.fileno()
line_buffer = []
last_ui_update = 0
pending_output = []
def maybe_set_kdc(ip: str):
if not ip:
return
if ansible_kdc:
return
if self._scan_kdc_value:
return
self._scan_kdc_value = ip
self.safe_after(lambda i=ip: self.kerberos_kdc.set(i))
self.safe_after(lambda: self.kerberos_enabled.set(True))
while True:
try:
# Use a smaller chunk size to encourage more frequent reads
chunk_bytes = os.read(fd, 1024)
except (EOFError, OSError):
chunk_bytes = b''
if not chunk_bytes:
if process.poll() is not None:
break
# If we have pending output, flush it before sleeping
if pending_output:
text_to_flush = "".join(pending_output)
pending_output = []
self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t))
last_ui_update = time.time()
time.sleep(0.01)
continue
text = chunk_bytes.decode('utf-8', errors='replace')
pending_output.append(text)
# Update more frequently if we have enough data or enough time has passed
now = time.time()
if now - last_ui_update > 0.05 or sum(len(x) for x in pending_output) > 2048:
text_to_flush = "".join(pending_output)
pending_output = []
self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t))
last_ui_update = now
# Accumulate for line parsing (for AD/KDC detection)
for char in text:
line_buffer.append(char)
if char == '\n':
line = "".join(line_buffer)
line_buffer = []
# Parse "KDC is: IP" from the new summary analysis format
if "KDC is:" in line:
try:
ip_part = line.split("KDC is:")[1].strip()
ip = ip_part.split()[0].strip('[]():,')
if ip:
maybe_set_kdc(ip)
except (IndexError, ValueError):
pass
elif "Active Directory" in line or "88" in line:
parts = line.split()
for part in parts:
try:
socket.inet_aton(part.strip('[]():,'))
ip = part.strip('[]():,')
maybe_set_kdc(ip)
break
except socket.error:
continue
process.wait()
# Final flush of any remaining output
if pending_output:
text_to_flush = "".join(pending_output)
self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t))
if process.returncode == 0:
self.safe_after(lambda: self.scan_status_var.set("Scan complete"))
else:
self.safe_after(lambda: self.scan_status_var.set(f"Scan failed (code {process.returncode})"))
self._scan_running = False
except Exception as e:
self.safe_after(lambda: self.scan_results_console.write(f"Scan error: {str(e)}\n"))
self.safe_after(lambda: self.scan_status_var.set("Scan failed"))
self._scan_running = False
threading.Thread(target=worker, daemon=True).start()