UI: Standardized console appearance and fixed cosmetic inconsistencies. This commit establishes a stable, consistent UI across the entire installer: Unified 'Light Background, Dark Text' theme for all console components. Standardized console creation routine to eliminate thick black borders on macOS. Refactored SSH key generation to use the standard canvas-based layout. Switched to tk.Frame and tk.Scrollbar for TerminalConsole to resist macOS dark mode shifts. Fixed shutil.SameFileError when PROLE_HOME matches the source directory. Improved notebook styling for inactive tabs and execution outputs. All screens now share the same professional look and feel.

This commit is contained in:
chrisfu 2026-01-27 22:32:27 -08:00
parent 453ccfd015
commit 58d693de05
4 changed files with 233 additions and 178 deletions

View File

@ -221,7 +221,8 @@ initialize() {
# Ensure port-forward is running for OpenBao (dependency)
echo "Ensuring port-forward for OpenBao is active ..."
"$SCRIPT_DIR/init_port_forwards.sh" restart openbao
"$SCRIPT_DIR/init_port_forwards.sh" restart openbao &
sleep 2
fetch_admin_keys_and_db_pass_from_bao_or_local
apply_cnpg_admin_secret
@ -231,7 +232,8 @@ initialize() {
# Ensure port-forward is running for Postgres (local access)
echo "Ensuring port-forward for Postgres is active ..."
"$SCRIPT_DIR/init_port_forwards.sh" restart postgres
"$SCRIPT_DIR/init_port_forwards.sh" restart postgres &
sleep 2
echo "Initialization complete for CNPG + Kerberos + cert artifacts."
}

View File

@ -459,13 +459,14 @@ case "$ACTION" in
if [[ -n "$db_pass" ]]; then
username=${PROLE_DB_USER:-"prole"}
echo "Creating database user secret 'prole-db-user' for '$username' ..."
echo "Checking/creating database user secret 'prole-db-user' for '$username' ..."
# Use apply to be idempotent
kubectl create secret generic prole-db-user -n "$NAMESPACE" \
--from-literal=username="$username" \
--from-literal=password="$db_pass" \
--dry-run=client -o yaml | kubectl apply -n "$NAMESPACE" -f -
echo "Creating database superuser secret 'prole-db-superuser' ..."
echo "Checking/creating database superuser secret 'prole-db-superuser' ..."
kubectl create secret generic prole-db-superuser -n "$NAMESPACE" \
--from-literal=username=postgres \
--from-literal=password="$db_pass" \
@ -476,13 +477,21 @@ case "$ACTION" in
# Ensure port-forward is running for OpenBao
echo "Ensuring port-forward for OpenBao is active ..."
"$SCRIPT_DIR/init_port_forwards.sh" restart openbao
"$SCRIPT_DIR/init_port_forwards.sh" restart openbao &
sleep 2
if ! curl -sS http://127.0.0.1:18200/v1/sys/health >/dev/null 2>&1; then
echo "ERROR: OpenBao not reachable at http://127.0.0.1:18200." >&2
echo "Please start port-forwards: $SCRIPT_DIR/init_port_forwards.sh start openbao" >&2
exit 1
fi
local max_retries=15
local count=0
while ! curl -sS http://127.0.0.1:18200/v1/sys/health >/dev/null 2>&1; do
if [[ $count -ge $max_retries ]]; then
echo "ERROR: OpenBao not reachable at http://127.0.0.1:18200 after waiting." >&2
echo "Please check logs: $SCRIPT_DIR/init_port_forwards.sh status openbao" >&2
exit 1
fi
echo "Waiting for OpenBao port-forward to be ready... ($count/$max_retries)"
sleep 2
count=$((count + 1))
done
init_openbao_kv_and_store_admin_key
echo "Initialization complete. Manifests in: $OPENBAO_MANIFEST_DIR"
;;

View File

@ -56,6 +56,12 @@ def get_resource_path(relative_path):
return base_path / relative_path
# Standard Console Theme (Light background, Dark text)
CONSOLE_BG = '#F5F5DC' # Cream/Light background similar to our images
CONSOLE_FG = '#1d1d1f' # Dark text
CONSOLE_INSERT = '#1d1d1f'
CONSOLE_FONT = ('Menlo', 10)
class ProleController:
"""Business logic for Prole installer, separated from UI."""
def __init__(self, project_root):
@ -99,25 +105,28 @@ class ProleController:
target_k8s = prole_home / "k8s"
source_k8s = self.project_root / "k8s"
if source_k8s.exists():
if not target_k8s.exists() or (source_k8s.stat().st_mtime > target_k8s.stat().st_mtime):
if target_k8s.exists():
shutil.rmtree(target_k8s)
shutil.copytree(source_k8s, target_k8s)
if source_k8s.resolve() != target_k8s.resolve():
if not target_k8s.exists() or (source_k8s.stat().st_mtime > target_k8s.stat().st_mtime):
if target_k8s.exists():
shutil.rmtree(target_k8s)
shutil.copytree(source_k8s, target_k8s)
# Ensure conf/postgresql is copied for version detection
target_conf = prole_home / "conf"
source_conf = self.project_root / "conf"
if source_conf.exists():
if not target_conf.exists() or (source_conf.stat().st_mtime > target_conf.stat().st_mtime):
# Only copy what we need or the whole thing? etc/ already exists in PROLE_HOME.
# Let's copy the whole conf dir if it doesn't exist or is older.
if target_conf.exists():
shutil.rmtree(target_conf)
shutil.copytree(source_conf, target_conf)
if source_conf.resolve() != target_conf.resolve():
if not target_conf.exists() or (source_conf.stat().st_mtime > target_conf.stat().st_mtime):
# Only copy what we need or the whole thing? etc/ already exists in PROLE_HOME.
# Let's copy the whole conf dir if it doesn't exist or is older.
if target_conf.exists():
shutil.rmtree(target_conf)
shutil.copytree(source_conf, target_conf)
# Copy script and set permissions
if source_script.exists():
shutil.copy2(source_script, target_script)
if source_script.resolve() != target_script.resolve():
shutil.copy2(source_script, target_script)
os.chmod(target_script, 0o755)
# Run from the installed location
@ -129,6 +138,7 @@ class ProleController:
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=0,
env=env
)
@ -197,7 +207,7 @@ class ProleInstaller:
self.sidebar.pack_propagate(False)
# Vertical Divider Line
self.divider = tk.Frame(self.main_container, bg='#CCCCCC', width=1)
self.divider = tk.Frame(self.main_container, bg='#CCCCCC', width=1, highlightthickness=0, bd=0)
self.divider.pack(side='left', fill='y')
# Right Content Area (approx 66%)
@ -509,6 +519,23 @@ class ProleInstaller:
bordercolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')],
lightcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')],
darkcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')])
# Notebook styling to match light theme and avoid dark mode shifts on macOS
self.style.theme_use('default')
self.style.configure('TNotebook', background='white', borderwidth=0, highlightthickness=0)
self.style.configure('TNotebook.Tab',
background='#F5F5DC',
foreground='black',
lightcolor='#F5F5DC',
bordercolor='#CCCCCC',
darkcolor='#F5F5DC',
borderwidth=1,
padding=[10, 5])
self.style.map('TNotebook.Tab',
background=[('selected', 'white')],
bordercolor=[('selected', '#CCCCCC')],
lightcolor=[('selected', 'white')],
focuscolor=[('selected', 'white')])
# Ensure common controls inherit white background
try:
@ -1068,8 +1095,25 @@ class ProleInstaller:
def _render_paragraph(self, text, y, wrap=600):
ui.render_paragraph(self, text, y, wrap)
def _create_console_output(self, y=280, title="Scan Output", width=880, height=450):
"""Create a standardized console output area with a label and scrollable text."""
# Section title/label for the console
ui.canvas_text(self, 48, y, title, fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))
y_console = y + 30
# Use a background frame to ensure NO borders are visible around the console
console_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0)
console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
console.pack(fill='both', expand=True, padx=1, pady=1)
console_window = self.bg_canvas.create_window(48, y_console, window=console_bg,
anchor='nw', width=width, height=height)
self._canvas_items.append(console_window)
self._overlay_widgets.append(console_bg)
self._overlay_widgets.append(console)
return console
def _render_welcome_page(self):
# Opening page should NOT perform any dependency checks.
# Letterhead at top right
content_width = self.bg_canvas.winfo_width() or 975 # 1300 * 0.75 approx
right_margin = content_width - 48
@ -1094,7 +1138,7 @@ class ProleInstaller:
"Welcome to the neighborhood! Let's get started by preparing your system for the Prole experience."
)
ui.canvas_text(self, 48, 210, welcome_text, fill='black', font=('SF Pro Text', 13), width=750)
ui.canvas_text(self, 48, 220, welcome_text, fill='black', font=('SF Pro Text', 13), width=750)
# Ensure footer is updated (Next button visible)
self.update_footer()
@ -1124,34 +1168,11 @@ class ProleInstaller:
pass
self.scan_status_var.trace_add('write', update_status_text)
y += 40
# Tabs for Scan Output and LLM Analysis
self.scan_notebook = ttk.Notebook(self.bg_canvas)
self.scan_tab = tk.Frame(self.scan_notebook, bg='white')
self.analysis_tab = tk.Frame(self.scan_notebook, bg='white')
# 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
self.scan_notebook.add(self.scan_tab, text="Scan Output")
self.scan_notebook.add(self.analysis_tab, text="AI Analysis", state='disabled')
# Output Box (placed inside scan_tab)
self.scan_results_text = scrolledtext.ScrolledText(self.scan_tab, width=110, height=22,
font=('Menlo', 10), bg='white', fg='black',
insertbackground='black', highlightthickness=1,
highlightbackground='#CCCCCC')
self.scan_results_text.pack(fill='both', expand=True, padx=5, pady=5)
# Analysis Box (placed inside analysis_tab)
self.analysis_results_text = scrolledtext.ScrolledText(self.analysis_tab, width=110, height=22,
font=('SF Pro Text', 11), bg='#F9F9F9', fg='black',
insertbackground='black', highlightthickness=1,
highlightbackground='#CCCCCC')
self.analysis_results_text.pack(fill='both', expand=True, padx=5, pady=5)
notebook_window = self.bg_canvas.create_window(48, y, window=self.scan_notebook, anchor='nw', width=880, height=450)
self._overlay_widgets.append(self.scan_notebook)
self._canvas_items.append(notebook_window)
y += 480
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,
@ -1166,7 +1187,7 @@ class ProleInstaller:
if not GLOBAL_SCAN_FRAMES:
try:
from PIL import Image, ImageTk
gif_path = PROJECT_ROOT / 'img' / 'prole-type.gif'
gif_path = get_resource_path('img/prole-type.gif')
if gif_path.exists():
gif = Image.open(str(gif_path))
try:
@ -1202,12 +1223,8 @@ class ProleInstaller:
return
self._scan_running = True
# Reset tabs
self.scan_notebook.tab(1, state='disabled')
self.scan_notebook.select(0)
self.scan_results_text.delete('1.0', tk.END)
self.scan_results_text.insert(tk.END, "Initializing network scan using prole-net/prole-scan ...\n")
self.scan_results_console.clear()
self.scan_results_console.write("Initializing network scan using prole-net/prole-scan ...\n")
self.scan_status_var.set("Scanning...")
# Start animation
@ -1235,7 +1252,7 @@ class ProleInstaller:
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
bufsize=0,
cwd=str(scan_dir))
# Capture output in real-time
@ -1244,8 +1261,11 @@ class ProleInstaller:
if not line and process.poll() is not None:
break
if line:
self.root.after(0, lambda l=line: self.scan_results_text.insert(tk.END, l))
self.root.after(0, lambda: self.scan_results_text.see(tk.END))
# Use a method that forces text onto the console
def _append(l):
self.scan_results_console.write(l)
self.scan_results_text.see(tk.END)
self.root.after(0, lambda l=line: _append(l))
# Heuristic parsing for Kerberos/AD to auto-fill
if "Active Directory" in line or "88" in line:
@ -1273,7 +1293,7 @@ class ProleInstaller:
self._scan_running = False
except Exception as e:
self.root.after(0, lambda: self.scan_results_text.insert(tk.END, f"Scan error: {str(e)}\n"))
self.root.after(0, lambda: self.scan_results_console.write(f"Scan error: {str(e)}\n"))
self.root.after(0, lambda: self.scan_status_var.set("Scan failed"))
self._scan_running = False
@ -1446,18 +1466,32 @@ class ProleInstaller:
["kinit", principal],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
stderr=subprocess.STDOUT,
text=True,
bufsize=0
)
stdout, stderr = process.communicate(input=password + "\n")
# Send password if needed
if password:
process.stdin.write(password + "\n")
process.stdin.close()
# Real-time output capture for Kerberos too
while True:
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
self.root.after(0, lambda l=line: self.kerberos_status_text.insert(tk.END, l))
self.root.after(0, lambda: self.kerberos_status_text.see(tk.END))
if process.returncode == 0:
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, "Success! Authenticated successfully.\n"))
# Get ticket details
klist_res = subprocess.run(["klist"], capture_output=True, text=True)
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"\nTicket details:\n{klist_res.stdout}"))
else:
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"Failed to authenticate.\nExit code: {process.returncode}\nError: {stderr}\n"))
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"Failed to authenticate.\nExit code: {process.returncode}\n"))
except Exception as e:
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"Error: {str(e)}\n"))
@ -1703,27 +1737,26 @@ class ProleInstaller:
# ---------------- Initialize Screen Handlers ----------------
def _generate_ssh_key_with_overlay(self):
"""Generate ed25519 SSH key pair with a status overlay."""
# Create a semi-transparent overlay
overlay_bg = ui.canvas_rectangle(self, 0, 0, 2000, 2000, fill='black', state='normal')
self.bg_canvas.itemconfig(overlay_bg, stipple='gray50') # Approximation of transparency
self._canvas_items.append(overlay_bg)
# Center console
cw = self.bg_canvas.winfo_width() or 975
ch = self.bg_canvas.winfo_height() or 800
"""Generate ed25519 SSH key pair using a standard screen layout."""
self._clear_canvas_page()
console_w, console_h = 700, 400
x = (cw - console_w) // 2
y = (ch - console_h) // 2
# Letterhead at top right
content_width = self.bg_canvas.winfo_width() or 975
right_margin = content_width - 48
console = ui.TerminalConsole(self.bg_canvas)
console_window = self.bg_canvas.create_window(x, y, window=console, anchor='nw', width=console_w, height=console_h)
self._canvas_items.append(console_window)
self._overlay_widgets.append(console)
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')
status_text = ui.canvas_text(self, x, y - 30, "Generating SSH Key Pair...", fill='white', font=('SF Pro Text', 14, 'bold'))
self._canvas_items.append(status_text)
self._render_title('Generate SSH Key', y=150)
self._render_paragraph('Generating ed25519 SSH key pair for secure database access.', y=200)
# Output Console - standardized to match Docker Build screen
console = self._create_console_output(y=260, title="SSH Output", width=900, height=520)
# Status Label
status_label = ui.canvas_text(self, 48, 812, "Initializing...", fill='black', font=('SF Pro Text', 12))
self._canvas_items.append(status_label)
def worker():
key_path = Path.home() / ".ssh" / "id_prole_ed25519"
@ -1731,8 +1764,10 @@ class ProleInstaller:
if key_path.exists():
console.write(f"Key already exists at {key_path}. Skipping generation.\n")
time.sleep(1)
self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="SSH key already exists. Proceeding...", fill='#34c759') if self.bg_canvas.winfo_exists() else None)
time.sleep(1.5)
else:
self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Generating key...", fill='blue') if self.bg_canvas.winfo_exists() else None)
cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", self.db_username.get()]
console.write(f"Running: {' '.join(cmd)}\n\n")
@ -1746,8 +1781,10 @@ class ProleInstaller:
if proc.returncode == 0:
console.write("\nSSH key generated successfully.\n")
self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="SSH key generated successfully.", fill='#34c759') if self.bg_canvas.winfo_exists() else None)
else:
console.write(f"\nError generating SSH key (code {proc.returncode})\n")
self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"Error generating key (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None)
# In case of error, we might want to let the user see it before continuing or stopping
time.sleep(2)
@ -1907,10 +1944,7 @@ class ProleInstaller:
self._render_paragraph('Building the prole-db Postgres image. This may take a few minutes.', y=200)
# Output Console
self._db_build_console = ui.TerminalConsole(self.bg_canvas)
console_window = self.bg_canvas.create_window(48, 260, window=self._db_build_console, anchor='nw', width=900, height=520)
self._canvas_items.append(console_window)
self._overlay_widgets.append(self._db_build_console)
self._db_build_console = self._create_console_output(y=260, title="Build Output", width=900, height=520)
# Use tk.Button
self._db_build_button = tk.Button(self.bg_canvas, text='Start Build', command=self.run_db_build,
@ -1953,9 +1987,10 @@ class ProleInstaller:
if source_dir.exists():
import shutil
# Remove old build dir and copy fresh
if build_dir.exists():
shutil.rmtree(build_dir)
shutil.copytree(source_dir, build_dir)
if source_dir.resolve() != build_dir.resolve():
if build_dir.exists():
shutil.rmtree(build_dir)
shutil.copytree(source_dir, build_dir)
cwd = build_dir
@ -2079,10 +2114,7 @@ class ProleInstaller:
self._render_paragraph('Deploying the CloudNative-PG operator and cluster manifests to Kubernetes.', y=200)
# Output Console
self._cnpg_deploy_console = ui.TerminalConsole(self.bg_canvas)
console_window = self.bg_canvas.create_window(48, 260, window=self._cnpg_deploy_console, anchor='nw', width=900, height=520)
self._canvas_items.append(console_window)
self._overlay_widgets.append(self._cnpg_deploy_console)
self._cnpg_deploy_console = self._create_console_output(y=260, title="Deployment Output", width=900, height=520)
# Use tk.Button
self._cnpg_deploy_button = tk.Button(self.bg_canvas, text='Run Deployment', command=self.run_cnpg_deploy,
@ -2191,10 +2223,17 @@ class ProleInstaller:
self._render_title('Initialization Scripts', y=150)
self._render_paragraph('Running initialization scripts to set up OpenBao, CloudNative-PG, and Port Forwards.', y=200)
# Tabs for output
self.script_tabs = ttk.Notebook(self.bg_canvas)
tab_window = self.bg_canvas.create_window(48, 260, window=self.script_tabs, anchor='nw', width=900, height=480)
# Tabs for output - using standardized appearance
ui.canvas_text(self, 48, 260, "Execution Output", fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))
# Use a background frame for the notebook to hide potential system borders
notebook_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0)
self.script_tabs = ttk.Notebook(notebook_bg, style='TNotebook')
self.script_tabs.pack(fill='both', expand=True, padx=1, pady=1)
tab_window = self.bg_canvas.create_window(48, 290, window=notebook_bg, anchor='nw', width=900, height=450)
self._canvas_items.append(tab_window)
self._overlay_widgets.append(notebook_bg)
self._overlay_widgets.append(self.script_tabs)
self.script_consoles = {}
@ -2208,10 +2247,12 @@ class ProleInstaller:
]
for title, fname in scripts:
frame = tk.Frame(self.script_tabs, bg='white')
self.script_tabs.add(frame, text=title)
console = ui.TerminalConsole(frame)
console.pack(fill='both', expand=True)
# Use a background frame to ensure NO borders are visible around the console
console_bg = tk.Frame(self.script_tabs, bg='white', highlightthickness=0, bd=0)
self.script_tabs.add(console_bg, text=title)
# Use TerminalConsole for consistent styling
console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
console.pack(fill='both', expand=True, padx=1, pady=1)
self.script_consoles[fname] = console
# Use tk.Button
@ -2222,7 +2263,7 @@ class ProleInstaller:
btn_window = self.bg_canvas.create_window(48, 760, window=self._init_scripts_button, anchor='nw', width=180)
self._canvas_items.append(btn_window)
self._overlay_widgets.append(self._init_scripts_button)
# Status Label
self._init_scripts_status_label = ui.canvas_text(self, 240, 772, "", fill='black', font=('SF Pro Text', 12))
self._canvas_items.append(self._init_scripts_status_label)
@ -2385,6 +2426,7 @@ class ProleInstaller:
self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Initialization failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None)
self._scripts_success = False
self.safe_after(lambda: self.update_footer())
self.safe_after(lambda: self._init_scripts_button.configure(state='normal') if self._init_scripts_button.winfo_exists() else None)
threading.Thread(target=worker, daemon=True).start()
@ -2590,10 +2632,7 @@ class ProleInstaller:
self.bg_canvas.bind('<Button-1>', _on_click)
# Output Console (Consistent size and location: y=260, width=900, height=520)
self._build_console = ui.TerminalConsole(self.bg_canvas)
console_window = self.bg_canvas.create_window(48, 260, window=self._build_console, anchor='nw', width=900, height=520)
self._canvas_items.append(console_window)
self._overlay_widgets.append(self._build_console)
self._build_console = self._create_console_output(y=230, title="Build Output", width=900, height=550)
self._console_text = self._build_console.text # For compatibility with _append_console and others
# Show a command preview with PS1-style prompt and blinking cursor
@ -2884,6 +2923,12 @@ class ProleInstaller:
self.next_button.configure(text='Next')
else:
self.next_button.configure(text='Build')
elif pid == 'init_scripts':
# Initialization Scripts: Next is disabled until success
if getattr(self, '_scripts_success', False):
self.next_button.configure(state='normal')
else:
self.next_button.configure(state='disabled')
elif pid == 'create_installer':
self.next_button.configure(text='Finish')
@ -3140,8 +3185,12 @@ class ProleInstaller:
ttk.Combobox(wrap, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18).pack(side='left', padx=10)
# Build output console
self.build_output = scrolledtext.ScrolledText(f, height=12, bg='#fafafa', fg='#1d1d1f')
self.build_output.pack(fill='both', expand=True, padx=24, pady=12)
# Use a background frame to ensure NO borders are visible around the console
console_bg = tk.Frame(f, bg='white', highlightthickness=0, bd=0)
console_bg.pack(fill='both', expand=True, padx=24, pady=12)
self.build_output_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
self.build_output_console.pack(fill='both', expand=True, padx=1, pady=1)
self.build_output = self.build_output_console.text
# self._register_page('build', f)
@ -3368,7 +3417,7 @@ echo "-------------------------------------------------------------------";
# ---------------- Embedded console helpers ----------------
def _ensure_console_overlay(self, radio_bottom_y: int = 160):
"""Create semi-transparent black backdrop and a ScrolledText console overlay.
"""Create semi-transparent backdrop and a ScrolledText console overlay.
The overlay is placed within slide_area between given top and bottom margins.
"""
# Ensure slide_area is visible
@ -3378,30 +3427,23 @@ echo "-------------------------------------------------------------------";
# Compute geometry within slide area
geom = self._compute_console_geometry(radio_bottom_y)
left, top, width, height = geom
# Draw a stippled rectangle on the canvas to simulate ~60% opacity
rect = ui.canvas_rectangle(self, left, top, left + width, top + height, fill='#000000', outline='')
try:
# Apply stipple directly on the canvas item if supported
self.bg_canvas.itemconfig(rect, stipple='gray50')
except Exception:
pass
self._canvas_items.append(rect)
# Create ScrolledText overlay
txt = scrolledtext.ScrolledText(self.slide_area, wrap='word',
bg='#000000', fg='#ffffff', insertbackground='#ffffff',
font=('Menlo', 11), relief='flat', bd=0, highlightthickness=0)
txt.place(x=left + 8, y=top + 8, width=max(50, width - 16), height=max(50, height - 16))
self._overlay_widgets.append(txt)
self._console_text = txt
# Use a background frame to ensure NO borders are visible around the console
console_bg = tk.Frame(self.slide_area, bg='white', highlightthickness=0, bd=0)
console_bg.place(x=left, y=top, width=width, height=height)
# Create TerminalConsole overlay
console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
console.pack(fill='both', expand=True, padx=1, pady=1)
self._overlay_widgets.append(console_bg)
self._overlay_widgets.append(console)
self._console_text = console.text
# Keep overlay positioned on resize
def _on_resize(_evt=None):
l, t, w, h = self._compute_console_geometry(radio_bottom_y)
try:
self.bg_canvas.coords(rect, l, t, l + w, t + h)
except Exception:
pass
try:
txt.place(x=l + 8, y=t + 8, width=max(50, w - 16), height=max(50, h - 16))
console_bg.place(x=l, y=t, width=w, height=h)
except Exception:
pass
# Bind to slide area; store bind id to unbind later
@ -3922,38 +3964,30 @@ echo "-------------------------------------------------------------------";
)
self._render_paragraph(tips, y=88)
logp = getattr(self, 'last_build_log_path', None)
# Place a scrolled text overlay to show the log
# Place a scrollable console to show the log
try:
# Use same geometry helper as console but with DARK theme (match Build console)
l, t, w, h = self._compute_console_geometry(160)
txt = scrolledtext.ScrolledText(self.slide_area, wrap='word',
bg='#000000', fg='#ffffff', insertbackground='#ffffff',
font=('Menlo', 11), relief='flat', bd=0, highlightthickness=0)
txt.place(x=l, y=t, width=max(200, w), height=max(140, h))
self._overlay_widgets.append(txt)
# Standardized Console Output area for build summary
self.build_summary_console = self._create_console_output(y=160, title="Build Log Output", width=900, height=520)
txt = self.build_summary_console.text
if logp and os.path.exists(logp):
try:
with open(logp, 'r', encoding='utf-8', errors='ignore') as fp:
content = fp.read()
txt.insert('1.0', content)
self.build_summary_console.write(content)
except Exception as e:
txt.insert('1.0', f"Failed to read log: {e}\n")
self.build_summary_console.write(f"Failed to read log: {e}\n")
else:
txt.insert('1.0', 'No build log available.')
txt.configure(state='disabled')
# Keep overlay positioned on resize
def _on_resize(_evt=None):
try:
l2, t2, w2, h2 = self._compute_console_geometry(160)
txt.place(x=l2, y=t2, width=max(200, w2), height=max(140, h2))
except Exception:
pass
self._overlay_bind_id = self.slide_area.bind('<Configure>', _on_resize)
self.build_summary_console.write('No build log available.')
# Keep standard y for links
y_links = 760
except Exception:
# Fallback: just show path
self._render_paragraph('No build log could be displayed.', y=120)
y_links = 140
# Add link to open the log file in Finder/TextEdit
y_links = 100 + 40
if logp:
link = ui.render_link(self, 56, y_links, 'Open build log file')
self._canvas_items.append(link)
@ -5157,7 +5191,7 @@ esac
def create_validate_screen(self):
"""Create the Validate screen"""
frame = tk.Frame(self.content_area, bg='#1a1a1a')
frame = self._page_container()
self.screens['validate'] = frame
# Title
@ -5165,19 +5199,19 @@ esac
title.pack(pady=(0, 20))
# Prometheus link
prometheus_frame = tk.Frame(frame, bg='#1a1a1a')
prometheus_frame = tk.Frame(frame, bg='white')
prometheus_frame.pack(pady=(0, 20))
prometheus_label = tk.Label(prometheus_frame,
text="Prometheus: ",
bg='#1a1a1a',
fg='#aaaaaa',
bg='white',
fg='#6e6e73',
font=('Helvetica', 11))
prometheus_label.pack(side='left')
prometheus_link = tk.Label(prometheus_frame,
text="http://localhost:9090",
bg='#1a1a1a',
bg='white',
fg='#4a9eff',
font=('Helvetica', 11, 'underline'),
cursor='hand2')
@ -5185,35 +5219,33 @@ esac
prometheus_link.bind('<Button-1>', lambda e: webbrowser.open('http://localhost:9090'))
# Status display
status_frame = tk.Frame(frame, bg='#1a1a1a')
status_frame = tk.Frame(frame, bg='white')
status_frame.pack(fill='both', expand=True, pady=10)
status_label = ttk.Label(status_frame, text="Cluster Status", style='Heading.TLabel')
status_label.pack(anchor='w', pady=(0, 10))
# Status text area
self.status_text = scrolledtext.ScrolledText(status_frame,
bg='#0a0a0a',
fg='#4a9eff',
font=('Courier', 10),
wrap='word',
relief='flat',
bd=1)
self.status_text.pack(fill='both', expand=True)
# Status text area - using TerminalConsole for consistency
# Use a background frame to ensure NO borders are visible around the console
console_bg = tk.Frame(status_frame, bg='white', highlightthickness=0, bd=0)
console_bg.pack(fill='both', expand=True, padx=1, pady=1)
self._validation_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
self._validation_console.pack(fill='both', expand=True)
self.status_text = self._validation_console.text
# Auto-refresh checkbox
refresh_frame = tk.Frame(frame, bg='#1a1a1a')
refresh_frame = tk.Frame(frame, bg='white')
refresh_frame.pack(pady=10)
self.auto_refresh_var = tk.BooleanVar(value=True)
refresh_check = tk.Checkbutton(refresh_frame,
text="Auto-refresh every 10 seconds",
variable=self.auto_refresh_var,
bg='#1a1a1a',
fg='#aaaaaa',
selectcolor='#2a2a2a',
activebackground='#1a1a1a',
activeforeground='#aaaaaa',
bg='white',
fg='#1d1d1f',
selectcolor='#f0f0f0',
activebackground='white',
activeforeground='#1d1d1f',
font=('Helvetica', 10),
command=self.toggle_auto_refresh)
refresh_check.pack(side='left', padx=10)
@ -5225,6 +5257,7 @@ esac
bg='#4a9eff',
fg='white',
activebackground='#3a8eef',
highlightbackground='white',
font=('Helvetica', 10),
padx=15,
pady=5,

View File

@ -191,20 +191,31 @@ def create_nav_footer(parent, buttons: list[tuple[int, str]], commands: dict[int
return btn_map
class TerminalConsole(ttk.Frame):
class TerminalConsole(tk.Frame):
"""A scrollable text widget that mimics a terminal console."""
def __init__(self, parent, **kwargs):
bg = '#F5F5DC' # Cream/Light background
kwargs.setdefault('bg', bg)
super().__init__(parent, **kwargs)
self.text = tk.Text(self, bg='#1e1e1e', fg='#f0f0f0', font=('Menlo', 11) if 'Darwin' in platform.system() else ('Consolas', 11),
padx=10, pady=10, insertbackground='white')
self.scroll = ttk.Scrollbar(self, orient='vertical', command=self.text.yview)
# Standard Console Theme (Light background, Dark text)
fg = '#1d1d1f' # Dark text
font = ('Menlo', 10) if 'Darwin' in platform.system() else ('Consolas', 10)
self.text = tk.Text(self, bg=bg, fg=fg, font=font,
padx=10, pady=10, insertbackground=fg,
highlightthickness=0, bd=0, relief='flat')
# Force a light scrollbar style on macOS to avoid dark mode shifts
self.scroll = tk.Scrollbar(self, orient='vertical', command=self.text.yview,
highlightthickness=0, bd=0, bg=bg,
activebackground=bg, troughcolor=bg)
self.text.configure(yscrollcommand=self.scroll.set)
self.scroll.pack(side='right', fill='y')
self.text.pack(side='left', fill='both', expand=True)
# Make read-only by default but allow selection and copying
self.text.configure(state='disabled')
self.text.configure(state='disabled', relief='flat')
def write(self, content: str):
"""Append text to the console and scroll to the bottom."""