mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 13:34:31 +00:00
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.
442 lines
21 KiB
Python
442 lines
21 KiB
Python
"""Dependency scanning, summary and per-dependency install screens."""
|
|
|
|
import re
|
|
import threading
|
|
import time
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
from installer import config as inst_config
|
|
from installer import screen as ui
|
|
|
|
|
|
class DependenciesScreenMixin:
|
|
"""Dependency scanning, summary and per-dependency install screens."""
|
|
|
|
def _render_deps_summary_page(self):
|
|
self._render_title('Dependencies', y=40)
|
|
y = 100
|
|
# Draw each dependency row: status dot (blank initially), name, info
|
|
row_gap = 36
|
|
|
|
self.dep_status_items = {} # Store canvas IDs to update later
|
|
self.dep_install_buttons = {} # Store button window items
|
|
|
|
for dep in self.dependencies:
|
|
indent = 0
|
|
if dep.get('parent'):
|
|
indent = 24
|
|
|
|
left = 56 + indent
|
|
text_x = left + 28
|
|
|
|
# blank box (outline only initially)
|
|
dot = ui.canvas_rectangle(self, left, y, left+18, y+18, outline='#6e6e73', width=2)
|
|
self._canvas_items.append(dot)
|
|
# name
|
|
self._canvas_items.append(ui.canvas_text(self, text_x, y-2, dep['name'], fill='black', font=('SF Pro Text', 12, 'bold')))
|
|
# info placeholder
|
|
info = ui.canvas_text(self, text_x + 180, y, 'Checking...', fill='#6e6e73', font=('SF Pro Text', 11))
|
|
self._canvas_items.append(info)
|
|
|
|
self.dep_status_items[dep['id']] = {'dot': dot, 'info': info, 'y': y, 'left': left}
|
|
y += row_gap
|
|
|
|
# Message line
|
|
msg_y = y + 30
|
|
self.deps_msg_item = ui.canvas_text(self, 48, msg_y, 'Scanning system...', fill='#6e6e73', font=('SF Pro Text', 11))
|
|
self._canvas_items.append(self.deps_msg_item)
|
|
|
|
# Start async check
|
|
threading.Thread(target=self._run_delayed_deps_check, daemon=True).start()
|
|
|
|
def _render_dependency_page(self, dep):
|
|
self._render_title(dep['name'], y=40)
|
|
self._render_paragraph(dep['description'], y=88)
|
|
# Status
|
|
ok, location, version = self.get_dep_info(dep)
|
|
y = 180
|
|
left = 56
|
|
if ok:
|
|
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline=''))
|
|
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installed', fill='black', font=('SF Pro Text', 12, 'bold')))
|
|
if location:
|
|
self._canvas_items.append(ui.canvas_text(self, left+32, y+32, f'Location: {location}', fill='#6e6e73', font=('SF Pro Text', 11)))
|
|
if version:
|
|
ver = self.normalize_version(version)
|
|
self._canvas_items.append(ui.canvas_text(self, left+32, y+56, f'Version: {ver}', fill='#6e6e73', font=('SF Pro Text', 11)))
|
|
else:
|
|
# Check if we should auto-install
|
|
install_cmd = dep.get('install_cmd')
|
|
if install_cmd and self._installing_dep_id != dep['id']:
|
|
# Launch directly into installation
|
|
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline=''))
|
|
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold')))
|
|
# Trigger console-based install for brew/pip
|
|
if 'brew install' in install_cmd or 'pip install' in install_cmd:
|
|
self.root.after(500, lambda: self._install_dep_in_console(dep))
|
|
else:
|
|
self.root.after(500, lambda: self.open_terminal_with_command(install_cmd))
|
|
elif self._installing_dep_id == dep['id']:
|
|
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline=''))
|
|
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold')))
|
|
else:
|
|
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff9f0a', outline=''))
|
|
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Not installed', fill='black', font=('SF Pro Text', 12, 'bold')))
|
|
# Click to install link (if available)
|
|
if install_cmd:
|
|
link_y = y + 40
|
|
link_text = ui.render_link(self, left+32, link_y, 'Click to install')
|
|
self._canvas_items.append(link_text)
|
|
self.bg_canvas.config(cursor='hand2')
|
|
|
|
def _on_click(event):
|
|
ex, ey = event.x, event.y
|
|
bbox = self.bg_canvas.bbox(link_text)
|
|
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
|
|
self.open_terminal_with_command(install_cmd)
|
|
self.bg_canvas.bind('<Button-1>', _on_click)
|
|
|
|
def _install_dep_in_console(self, dep):
|
|
"""Run dependency installation in the embedded console."""
|
|
if self._installing_dep_id == dep['id']:
|
|
return
|
|
try:
|
|
self._action_flags[f"dependencies.{dep['id']}.install"] = True
|
|
except Exception:
|
|
pass
|
|
|
|
install_cmd = dep.get('install_cmd')
|
|
if not install_cmd:
|
|
return
|
|
|
|
self._installing_dep_id = dep['id']
|
|
# Prepare log file
|
|
logs_dir = self._resolve_env_dir('PROLE_LOGS', 'logs')
|
|
try:
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
ts = time.strftime('%Y%m%d-%H%M%S')
|
|
log_path = logs_dir / f"install-{dep['id']}-{ts}.log"
|
|
|
|
# Show console
|
|
self._ensure_console_overlay(radio_bottom_y=160)
|
|
self._console_text.configure(state='normal')
|
|
self._console_text.delete('1.0', tk.END)
|
|
self._console_text.insert('end', f"Starting installation of {dep['name']}...\n")
|
|
self._console_text.insert('end', f"Command: {install_cmd}\n\n")
|
|
self._console_text.configure(state='disabled')
|
|
|
|
def on_done(rc):
|
|
self._installing_dep_id = None
|
|
if rc == 0:
|
|
self._append_console(f"\nSuccessfully installed {dep['name']}.\n")
|
|
# Refresh status and re-render page
|
|
self.root.after(1500, lambda: self.show_page(f"dep_{dep['id']}"))
|
|
else:
|
|
self._append_console(f"\nInstallation failed with exit code {rc}.\n")
|
|
# Enable next button so user can retry or proceed if they fixed it manually
|
|
try:
|
|
self.next_button.configure(state='normal', text='Next')
|
|
except Exception:
|
|
pass
|
|
|
|
self._run_in_console(install_cmd, str(log_path), on_complete=on_done)
|
|
|
|
# ---------------- Initialize Screen Handlers ----------------
|
|
|
|
def _run_delayed_deps_check(self):
|
|
results = {}
|
|
any_missing = False
|
|
for dep in self.dependencies:
|
|
ok, location, version = self.get_dep_info(dep)
|
|
results[dep['id']] = (ok, location, version)
|
|
|
|
# Update UI for this item
|
|
def update_item(did=dep['id'], ok=ok, version=version):
|
|
items = self.dep_status_items.get(did)
|
|
if not items: return
|
|
|
|
# Replace box with colored dot/check
|
|
self.bg_canvas.delete(items['dot'])
|
|
left = items.get('left', 56)
|
|
y = items['y']
|
|
if ok:
|
|
items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='')
|
|
# Add a small white checkmark inside the green dot
|
|
self.bg_canvas.create_line(left+5, y+9, left+8, y+12, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}")
|
|
self.bg_canvas.create_line(left+8, y+12, left+13, y+6, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}")
|
|
else:
|
|
items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff3b30', outline='')
|
|
# Add "Click to install" button
|
|
btn = tk.Button(self.bg_canvas, text='Install', command=lambda d=did: self.show_page(f"dep_{d}"),
|
|
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
|
highlightbackground='#F5F5DC', highlightthickness=0,
|
|
relief='flat', font=('SF Pro Text', 10), padx=8, pady=2)
|
|
btn_window = self.bg_canvas.create_window(left + 350, y, window=btn, anchor='nw')
|
|
self._overlay_widgets.append(btn)
|
|
self._canvas_items.append(btn_window)
|
|
self.dep_install_buttons[did] = btn_window
|
|
|
|
info_text = self.normalize_version(version) if ok and version else ('Not installed' if not ok else '')
|
|
self.bg_canvas.itemconfig(items['info'], text=info_text)
|
|
|
|
self.safe_after(update_item)
|
|
time.sleep(0.1) # small delay to show it checking one by one
|
|
|
|
def final_update():
|
|
missing = [d['name'] for d in self.dependencies if not results.get(d['id'], (False, None, None))[0]]
|
|
if missing:
|
|
msg = f"Preparing to install ... {', '.join(missing)}"
|
|
else:
|
|
msg = 'All dependencies installed.'
|
|
try:
|
|
if self.bg_canvas.winfo_exists() and self.deps_msg_item in self.bg_canvas.find_all():
|
|
self.bg_canvas.itemconfig(self.deps_msg_item, text=msg)
|
|
except (tk.TclError, RuntimeError):
|
|
pass
|
|
self.update_footer()
|
|
|
|
self.safe_after(final_update)
|
|
|
|
def _create_page_dependencies_summary(self):
|
|
f = self._page_container()
|
|
ttk.Label(f, text='Dependencies', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
|
self.deps_container = ttk.Frame(f)
|
|
self.deps_container.pack(fill='both', expand=True, padx=16, pady=8)
|
|
|
|
self.dep_status = {}
|
|
for dep in self.dependencies:
|
|
row = ttk.Frame(self.deps_container)
|
|
row.pack(fill='x', pady=6)
|
|
indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0)
|
|
indicator.pack(side='left', padx=8)
|
|
name = ttk.Label(row, text=dep['name'], style='Body.TLabel')
|
|
name.pack(side='left')
|
|
info = ttk.Label(row, text='', style='Dim.TLabel')
|
|
info.pack(side='left', padx=10)
|
|
self.dep_status[dep['id']] = {'canvas': indicator, 'info': info, 'dep': dep}
|
|
# Verify all checkbox (controls navigation behavior)
|
|
chk = ttk.Checkbutton(f, text='Verify all dependencies', variable=self.verify_mode, command=self.update_footer)
|
|
chk.pack(anchor='w', padx=24, pady=(4, 0))
|
|
|
|
# Message label must be created before refresh to avoid AttributeError
|
|
self.deps_msg = ttk.Label(f, text='', style='Dim.TLabel')
|
|
self.deps_msg.pack(anchor='w', padx=24, pady=(8, 8))
|
|
# Now we can safely populate the UI. Start async scan to avoid startup delay
|
|
self.deps_msg.configure(text='Checking dependencies...')
|
|
self.start_dependency_scan()
|
|
# self._register_page('deps_summary', f)
|
|
|
|
def _create_page_dependency(self, dep):
|
|
f = self._page_container()
|
|
ttk.Label(f, text=dep['name'], style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
|
ttk.Label(f, text=dep['description'], style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24)
|
|
status_var = tk.StringVar(value='Checking...')
|
|
loc_var = tk.StringVar(value='')
|
|
ver_var = tk.StringVar(value='')
|
|
|
|
info_frame = ttk.Frame(f)
|
|
info_frame.pack(fill='x', padx=24, pady=12)
|
|
# Pretty status row with an icon
|
|
status_row = ttk.Frame(info_frame)
|
|
status_row.pack(anchor='w', fill='x')
|
|
status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0)
|
|
status_icon.pack(side='left', padx=(0, 8), pady=(2, 0))
|
|
ttk.Label(status_row, textvariable=status_var, style='Body.TLabel').pack(side='left')
|
|
|
|
# Detail rows (hidden when not installed)
|
|
loc_label = ttk.Label(info_frame, textvariable=loc_var, style='Dim.TLabel')
|
|
ver_label = ttk.Label(info_frame, textvariable=ver_var, style='Dim.TLabel')
|
|
loc_label.pack(anchor='w')
|
|
ver_label.pack(anchor='w')
|
|
|
|
link = ttk.Label(f, text='Click to install', foreground='#0a84ff', cursor='hand2', style='Body.TLabel')
|
|
link.pack(anchor='w', padx=24, pady=(12, 0))
|
|
|
|
def _draw_status_icon(canvas, status):
|
|
ui.canvas_clear(canvas)
|
|
if status == 'installed':
|
|
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#34c759', outline='')
|
|
ui.canvas_line_on(canvas, 4, 9, 8, 13, fill='white', width=2)
|
|
ui.canvas_line_on(canvas, 8, 13, 15, 5, fill='white', width=2)
|
|
elif status == 'missing':
|
|
# Friendly warning dot with exclamation
|
|
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#ff9f0a', outline='')
|
|
ui.canvas_line_on(canvas, 9, 5, 9, 11, fill='white', width=2)
|
|
ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill='white', outline='white')
|
|
|
|
def check_then_update():
|
|
ok, location, version = self.get_dep_info(dep)
|
|
if ok:
|
|
status_var.set('Installed')
|
|
_draw_status_icon(status_icon, 'installed')
|
|
loc_var.set(f'Location: {location or ""}')
|
|
norm_ver = self.normalize_version(version) if version else None
|
|
ver_var.set(f'Version: {norm_ver or ""}')
|
|
# Ensure details visible
|
|
try:
|
|
loc_label.pack_configure()
|
|
ver_label.pack_configure()
|
|
link.pack_forget()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
status_var.set('Not installed')
|
|
_draw_status_icon(status_icon, 'missing')
|
|
# Hide undefined details instead of showing dashes
|
|
try:
|
|
loc_label.pack_forget()
|
|
ver_label.pack_forget()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
# Keep install link visible if we have a command
|
|
if dep.get('install_cmd') and not link.winfo_ismapped():
|
|
link.pack(anchor='w', padx=24, pady=(12, 0))
|
|
except Exception:
|
|
pass
|
|
|
|
self.root.after(50, check_then_update)
|
|
|
|
def do_install(event=None):
|
|
self.open_terminal_with_command(dep.get('install_cmd'))
|
|
|
|
if dep.get('install_cmd'):
|
|
link.bind('<Button-1>', do_install)
|
|
else:
|
|
try:
|
|
link.pack_forget()
|
|
except Exception:
|
|
pass
|
|
|
|
# self._register_page(f'dep_{dep["id"]}', f)
|
|
|
|
def _dep_navigation_sequence(self, force_all: bool = False):
|
|
"""Return a list of dependency page ids to traverse next.
|
|
- If force_all is True or verify_mode is True: include all dep pages in defined order.
|
|
- Else: include only missing dependency pages based on current checks.
|
|
"""
|
|
if force_all or self.verify_mode.get():
|
|
res = [f'dep_{d["id"]}' for d in self.dependencies]
|
|
print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}")
|
|
return res
|
|
seq = []
|
|
for d in self.dependencies:
|
|
ok, _, _ = self.get_dep_info(d)
|
|
if not ok:
|
|
seq.append(f'dep_{d["id"]}')
|
|
print(f"[DEBUG] _dep_navigation_sequence: {seq}")
|
|
return seq
|
|
|
|
def start_dependency_scan(self):
|
|
"""Scan dependencies in a background thread to avoid blocking UI startup."""
|
|
if self.validation_running:
|
|
return
|
|
self.validation_running = True
|
|
|
|
# Hold incremental results so we can update the status line and icons progressively
|
|
self._dep_scan_results = {}
|
|
|
|
def worker():
|
|
for dep in self.dependencies:
|
|
try:
|
|
ok, location, version = self.get_dep_info(dep)
|
|
except Exception:
|
|
ok, location, version = False, None, None
|
|
did = dep['id']
|
|
# Save and apply incrementally
|
|
self._dep_scan_results[did] = (ok, location, version)
|
|
self.safe_after(lambda d=did, o=ok, l=location, v=version: self._apply_dependency_incremental(d, o, l, v))
|
|
try:
|
|
time.sleep(0.02)
|
|
except Exception:
|
|
pass
|
|
# After all are processed, finalize pass to unify any remaining labels
|
|
self.safe_after(lambda: self._apply_dependency_scan(dict(self._dep_scan_results)))
|
|
|
|
t = threading.Thread(target=worker, daemon=True)
|
|
self.validation_thread = t
|
|
t.start()
|
|
|
|
def _apply_dependency_incremental(self, dep_id: str, ok: bool, location, version):
|
|
"""Update the dependencies page row and message as each check completes."""
|
|
if not hasattr(self, 'dep_status'):
|
|
return
|
|
slot = self.dep_status.get(dep_id)
|
|
if not slot:
|
|
return
|
|
# Update dot icon and info text for this row
|
|
self._draw_status(slot['canvas'], 'success' if ok else 'error')
|
|
if ok:
|
|
norm_ver = self.normalize_version(version) if version else ''
|
|
slot['info'].configure(text=(norm_ver or ''))
|
|
else:
|
|
slot['info'].configure(text='Not installed')
|
|
|
|
# Update the message line with current missing list
|
|
if hasattr(self, '_dep_scan_results'):
|
|
missing = [self.dep_status[d]['dep']['name'] for d, res in self._dep_scan_results.items() if not res[0] and d in self.dep_status]
|
|
if hasattr(self, 'deps_msg') and self.deps_msg is not None:
|
|
if missing:
|
|
self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}")
|
|
else:
|
|
self.deps_msg.configure(text='All dependencies installed.')
|
|
# Footer may need to react if verify mode is enabled
|
|
self.update_footer()
|
|
|
|
# Update prole.cfg data
|
|
try:
|
|
status_str = f"installed (version {version})" if ok else "not installed"
|
|
self.prole_cfg_data['Dependencies'][dep_id] = status_str
|
|
self._save_prole_cfg()
|
|
except Exception:
|
|
pass
|
|
|
|
def _apply_dependency_scan(self, results: dict):
|
|
"""Apply scan results to the summary UI and footer labels."""
|
|
self.validation_running = False
|
|
any_missing = False
|
|
for did, slot in self.dep_status.items():
|
|
ok, location, version = results.get(did, (False, None, None))
|
|
self._draw_status(slot['canvas'], 'success' if ok else 'error')
|
|
if ok:
|
|
norm_ver = self.normalize_version(version) if version else ''
|
|
slot['info'].configure(text=(norm_ver or ''))
|
|
else:
|
|
any_missing = True
|
|
slot['info'].configure(text='Not installed')
|
|
|
|
has_msg = hasattr(self, 'deps_msg') and self.deps_msg is not None
|
|
if any_missing:
|
|
missing = [slot['dep']['name'] for did, slot in self.dep_status.items() if not results.get(did, (False, None, None))[0]]
|
|
if missing and has_msg:
|
|
self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}")
|
|
else:
|
|
if has_msg:
|
|
self.deps_msg.configure(text='All dependencies installed.')
|
|
self.update_footer()
|
|
|
|
def refresh_dependencies_ui(self):
|
|
"""Deprecated synchronous refresh retained for compatibility.
|
|
Prefer start_dependency_scan -> _apply_dependency_scan.
|
|
"""
|
|
self.start_dependency_scan()
|
|
|
|
def all_dependencies_installed(self):
|
|
for dep in self.dependencies:
|
|
ok, _, _ = self.get_dep_info(dep)
|
|
if not ok:
|
|
print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING")
|
|
return False
|
|
print("[DEBUG] all_dependencies_installed: YES (all OK)")
|
|
return True
|
|
|
|
def get_dep_info(self, dep):
|
|
"""Delegate dependency probing to installer.config.get_dep_info."""
|
|
return inst_config.get_dep_info(dep)
|
|
|
|
def normalize_version(self, text: str) -> str:
|
|
"""Normalize versions via installer.config.normalize_version."""
|
|
return inst_config.normalize_version(text)
|