prole/installer/ui/screens/navigation.py
chrisfu 5618b662dd Remove prole-db-manager; simplify deployment via prole-authority; fix pg18 downgrade & cluster name
Summary:
Removed the prole-db-manager microservice and simplified deployment to use
prole-authority as the internal management and authorization point. Fixed two
blocking bugs that prevented silent install from completing on knoe-dev-cluster.

Removed: prole-db-manager
- Deleted db-manager-deployment.yaml and db-manager-service.yaml from opentofu manifests
- Deleted src/db-manager/ (Dockerfile, server.js, package.json, tests)
- Removed prole-db-manager port-forward mapping from installer/core/env.py
- Removed init_db_manager.sh from Initialization Scripts (milestones.py, actions.py)
- Removed init_certmgr.sh and init_db_manager.sh tabs from services screen (services.py)
- Removed live k8s Deployment/Service from knoe-dev-cluster

Fixed: PostgreSQL version downgrade error (pg17 -> pg18)
- Created conf/postgresql/.version with value 18
- Updated k8s/prole/prole-db.yaml and prole-db-recovery.yaml.tpl imageName to prole-db:18-089
- Fixed _init_database_options_state() to restore saved version_type from prole.cfg
  so db_version_type defaults to v18 (pg18) instead of silently reverting to pg17
- Added database_options.* keys to _collect_input_snapshot() in cfg.py so
  distribution, version_type, and all extension toggles persist to prole.cfg

Fixed: Cluster name inconsistency
- Removed stale prole-dev-cluster references; all scripts now use knoe-dev-cluster
- Added knoe-dev-cluster to mode-detection case in etc/prole_cfg.sh

Config: conf/prole.cfg
- Set kerberos_config.enabled = False, KERBEROS_AUTO_ENABLED = False
- Added database_options.distribution = percona, version_type = v18
- Added all 13 extension flags set to True (postgis, pgvector, pgcrypto, pgaudit,
  pg_repack, pg_stat_statements, pg_buffercache, pg_freespacemap, pgrowlocks,
  postgres_fdw, dblink, pg_stat_monitor, pgbadger)

Verification:
./install.py -s -l -v -c conf/prole.cfg completed successfully.
CNPG deployed prole-db:18-089 to knoe-dev-cluster; all milestones passed.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-01 20:40:44 -08:00

727 lines
27 KiB
Python

"""Sidebar navigation, page transitions, footer and app identity."""
import os
import platform
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from installer import config as inst_config
from installer.core.env import get_resource_path
class NavigationMixin:
"""Sidebar navigation, page transitions, footer and app identity."""
def _set_app_identity(self):
"""Set the installer identity: process/menu name and Dock icon on macOS.
Notes:
- We set Tk's appname for consistency.
- On macOS, attempt to set the process/menu name to 'Prole Database Installer'
via NSProcessInfo if PyObjC is available.
- Prefer the Prole Tools.app .icns from the built app; fallback to local PNG/GIF.
"""
# Set Tk application name
try:
self.root.tk.call("tk", "appname", "Prole Database Installer")
except Exception:
pass
# macOS: set process/menu name and Dock icon via AppKit/Foundation
if platform.system() == "Darwin":
# Try to set the visible process name for the menu bar
try:
from Foundation import NSProcessInfo
NSProcessInfo.processInfo().setProcessName_("Prole Database Installer")
except Exception:
pass
# Also try to retitle the first main menu item so the menu next to the Apple logo reads 'Prole Database Installer'
try:
from AppKit import NSApplication
app = NSApplication.sharedApplication()
main_menu = app.mainMenu()
if main_menu is not None and main_menu.numberOfItems() > 0:
first_item = main_menu.itemAtIndex_(0)
if first_item is not None:
first_item.setTitle_("Prole Database Installer")
except Exception:
pass
icns_candidates = [
get_resource_path(
"prole-app/dist/Prole Tools.app/Contents/Resources/Prole Tools.icns"
),
get_resource_path(
"prole-app/dist/Prole Tools.app/Contents/Resources/Prole.icns"
),
]
icns_path = next((p for p in icns_candidates if p.exists()), None)
if icns_path is not None:
try:
# Use PyObjC if available
from AppKit import NSApplication, NSImage
img = NSImage.alloc().initWithContentsOfFile_(str(icns_path))
if img is not None:
NSApplication.sharedApplication().setApplicationIconImage_(img)
return
except Exception:
pass
# If no .icns was found, try setting Dock icon from configured PNG
try:
from AppKit import NSApplication, NSImage
cfg_icon = inst_config.get_ui_icon_image_path()
if cfg_icon.exists():
png_img = NSImage.alloc().initWithContentsOfFile_(str(cfg_icon))
if png_img is not None:
NSApplication.sharedApplication().setApplicationIconImage_(
png_img
)
# do not return; still set Tk icon below for consistency
except Exception:
pass
# Fallback: Tk icon from image assets (PNG/GIF)
# Prefer config-defined icon image
try:
cfg_icon = inst_config.get_ui_icon_image_path()
except Exception:
cfg_icon = get_resource_path("img/proleIcon.png")
img_candidates = [
cfg_icon,
get_resource_path("img/prole-type.png"),
get_resource_path("img/prole-type.gif"),
get_resource_path("img/Prole.png"),
get_resource_path("img/proleLogoSepia.png"),
]
for p in img_candidates:
try:
if p.exists():
self._app_iconphoto = tk.PhotoImage(file=str(p))
try:
self.root.iconphoto(True, self._app_iconphoto)
except Exception:
pass
break
except Exception:
continue
def show_config_dialog(self):
"""Show configuration dialog for Docker image import directory."""
dialog = tk.Toplevel(self.root)
dialog.title("Configuration")
dialog.geometry("500x200")
dialog.resizable(False, False)
dialog.transient(self.root)
dialog.grab_set()
# Center on parent
x = self.root.winfo_x() + (self.root.winfo_width() // 2) - 250
y = self.root.winfo_y() + (self.root.winfo_height() // 2) - 100
dialog.geometry(f"+{x}+{y}")
container = tk.Frame(dialog, padx=20, pady=20)
container.pack(fill="both", expand=True)
tk.Label(
container,
text="Docker Image Import Directory:",
font=("SF Pro Text", 12, "bold"),
).pack(anchor="w")
tk.Label(
container,
text="Images found here will be loaded instead of pulled from Docker Hub.",
font=("SF Pro Text", 10),
fg="#666666",
).pack(anchor="w", pady=(0, 10))
row = tk.Frame(container)
row.pack(fill="x")
entry = tk.Entry(
row,
textvariable=self.docker_import_dir,
font=("SF Pro Text", 11),
bg="white",
fg="black",
insertbackground="black",
)
entry.pack(side="left", fill="x", expand=True, padx=(0, 5))
def browse():
path = filedialog.askdirectory(
initialdir=self.docker_import_dir.get() or Path.home()
)
if path:
self.docker_import_dir.set(path)
tk.Button(row, text="Browse...", command=browse).pack(side="right")
def save():
path = self.docker_import_dir.get().strip()
self.prole_cfg_data["Global"]["DOCKER_IMPORT_DIR"] = path
self._save_prole_cfg()
dialog.destroy()
btns = tk.Frame(container)
btns.pack(fill="x", pady=(20, 0))
tk.Button(btns, text="Save", command=save, width=10).pack(side="right")
tk.Button(btns, text="Cancel", command=dialog.destroy, width=10).pack(
side="right", padx=10
)
def configure_styles(self):
"""Configure ttk styles"""
base_bg = "white"
self.style.configure("TFrame", background="white")
self.style.configure(
"TLabel", background="white", foreground="black", font=("SF Pro Text", 11)
)
self.style.configure("Header.TLabel", font=("SF Pro Text", 16, "bold"))
self.style.configure("Small.TLabel", font=("SF Pro Text", 10))
# Consistent button styling
self.style.configure(
"Nav.TButton",
font=("SF Pro Text", 11),
padding=(12, 8),
background="#F5F5DC",
bordercolor="#F5F5DC",
lightcolor="#F5F5DC",
darkcolor="#F5F5DC",
relief="flat",
)
# Map state colors to avoid black boxes on hover/active
self.style.map(
"Nav.TButton",
background=[("active", "#E5E5D5"), ("pressed", "#D5D5C5")],
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:
self.style.configure("TCheckbutton", background=base_bg, foreground="black")
self.style.configure(
"TCombobox", fieldbackground="white", background=base_bg
)
except Exception:
pass
def _create_sidebar_nav(self):
"""Create the left-hand navigation menu."""
tk.Label(
self.sidebar,
text="INSTALLER",
bg="#F5F5DC",
fg="#8B8B7A",
font=("SF Pro Text", 10, "bold"),
anchor="w",
).pack(fill="x", padx=20, pady=(20, 10))
for text, page_id in self.nav_items:
lbl = tk.Label(
self.sidebar,
text=text,
bg="#F5F5DC",
fg="black",
font=("SF Pro Text", 11),
anchor="w",
padx=20,
pady=5,
cursor="hand2",
)
lbl.pack(fill="x")
lbl.bind("<Button-1>", lambda e, p=page_id: self.show_page(p))
self.nav_widgets[page_id] = lbl
def _update_nav_highlight(self, active_id):
"""Highlight the current active page in the sidebar."""
# Handle dep_id mapping to 'Dependencies'
if active_id.startswith("dep_"):
active_id = "deps_summary"
for page_id, widget in self.nav_widgets.items():
if page_id == active_id:
widget.configure(bg="#E5E5D5", font=("SF Pro Text", 11, "bold"))
else:
widget.configure(bg="#F5F5DC", font=("SF Pro Text", 11))
def create_navigation(self):
pass
def on_prev(self):
# Custom prev navigation for dependency pages when filtering
current_id = self.pages[self.page_index][0]
# Close Terminal if leaving build summary via Prev
if current_id == "build_summary":
try:
self.close_build_terminal()
except Exception:
pass
if current_id.startswith("dep_"):
seq = self._dep_navigation_sequence()
try:
i = seq.index(current_id)
except ValueError:
i = -1
if i > 0:
self.show_page(seq[i - 1])
return
else:
# Go back to summary if there is no previous in sequence
self.show_page("deps_summary")
return
if current_id == "deps_summary":
self.show_page("welcome")
return
if current_id == "network_scan":
# Go back to last relevant dep or deps_summary
if self.all_dependencies_installed():
self.show_page("deps_summary")
else:
seq = self._dep_navigation_sequence()
self.show_page(seq[-1] if seq else "deps_summary")
return
if current_id == "env_setup":
self.show_page("network_scan")
return
if current_id == "init_cluster":
if getattr(self, "_showing_service_overlay", False):
self._showing_service_overlay = False
self.show_page("init_cluster")
return
self.show_page("env_setup")
return
if current_id == "database_options":
self.show_page("init_cluster")
return
if current_id == "init_password":
self.show_page("init_db_build")
return
if current_id == "init_db_build":
self.show_page("database_options")
return
if current_id == "init_scripts":
self.show_page("init_password")
return
if current_id == "kerberos_config":
self.show_page("init_scripts")
return
if current_id == "gitops_config":
self.show_page("kerberos_config")
return
if current_id == "supabase_config":
self.show_page("gitops_config")
return
if current_id == "ollama_config":
self.show_page("supabase_config")
return
if current_id == "init_cnpg_deploy":
self.show_page("ollama_config")
return
if current_id == "create_installer":
self.show_page("init_cnpg_deploy")
return
# Default prev
if self.page_index > 0:
self.show_page(self.page_index - 1)
return
def on_next(self):
# Special handling for dynamic labels
current_id = self.pages[self.page_index][0]
print(
f"[DEBUG] on_next: current_id='{current_id}', page_index={self.page_index}"
)
if current_id == "welcome":
self.show_page("deps_summary")
return
if current_id == "deps_summary":
# Determine where to go from summary
# Always go to the next dependency or Network Scan
if self.all_dependencies_installed():
print("[DEBUG] on_next: all deps installed, going to network_scan")
self.show_page("network_scan")
return
# Go to first missing dependency page
seq = self._dep_navigation_sequence()
print(f"[DEBUG] on_next: missing deps sequence: {seq}")
if seq:
self.show_page(seq[0])
else:
print(
"[DEBUG] on_next: all deps seem OK in sequence, going to network_scan"
)
self.show_page("network_scan")
return
if current_id.startswith("dep_"):
# Navigate within dependency sequence
seq = self._dep_navigation_sequence()
print(f"[DEBUG] on_next: dep sequence: {seq}")
try:
i = seq.index(current_id)
except ValueError:
i = -1
if i >= 0 and i < len(seq) - 1:
self.show_page(seq[i + 1])
return
else:
# After last relevant dep page, go back to summary if anything is still missing
if not self.all_dependencies_installed():
print(
"[DEBUG] on_next: some deps still missing, returning to deps_summary"
)
self.show_page("deps_summary")
else:
print(
"[DEBUG] on_next: all deps now installed, going to network_scan"
)
self.show_page("network_scan")
return
if current_id == "network_scan":
# Capture network scan info
try:
self.prole_cfg_data["Network"][
"KDC_AUTO_DETECTED"
] = self.kerberos_kdc.get()
self.prole_cfg_data["Network"]["KERBEROS_AUTO_ENABLED"] = str(
self.kerberos_enabled.get()
)
self._save_prole_cfg()
except Exception:
pass
self.show_page("env_setup")
return
if current_id == "env_setup":
# Collect values, validate, write env.sh, then go to Database Creation
vals = {}
try:
for k in (
"PROLE_HOME",
"PROLE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"PROLE_SERVICE",
):
vals[k] = self._env_entries[k].get().strip()
self.prole_cfg_data["System Environment"][k] = vals[k]
vals["NAMESPACE"] = (self.db_namespace.get() or "").strip()
except Exception:
vals = self._env_defaults()
# Basic validation: require non-empty PROLE_HOME
if not vals.get("PROLE_HOME"):
try:
messagebox.showerror("Environment", "Please specify PROLE_HOME")
except Exception:
pass
return
try:
self._save_env_to_file(vals)
# Reload our environment so subsequent steps (Build) inherit it
self._after_env_saved()
except Exception as e:
try:
messagebox.showwarning("Environment", f"Could not save env.sh: {e}")
except Exception:
pass
return
self.show_page("init_cluster")
return
if current_id == "init_password":
p1 = self.db_password.get()
p2 = self.db_password_confirm.get()
if not p1:
try:
messagebox.showerror("Password", "Password cannot be empty.")
except Exception:
pass
return
if p1 != p2:
try:
messagebox.showerror("Password", "Passwords do not match.")
except Exception:
pass
return
if not self._validate_port_forward_overlaps():
return
self._run_preparation_overlay()
return
if current_id == "init_db_build":
self.show_page("init_password")
return
if current_id == "build":
if getattr(self, "_built_success", False):
self.show_page("create_installer")
else:
self.perform_build()
return
if current_id == "init_cluster":
if not self._validate_and_save_cluster_config():
return
if not self._cluster_ready_for_navigation():
return
if getattr(self, "_all_services_green", False):
# All common services healthy; skip repair/redeploy and proceed
self._common_services_success = True
self.show_page("database_options")
return
if not getattr(self, "_common_services_success", False):
self._run_service_layer_overlay(next_page="database_options")
return
self.show_page("database_options")
return
if current_id == "database_options":
if self._generate_prole_db_dockerfile():
self.show_page("init_db_build")
return
if current_id == "init_scripts":
self.prole_cfg_data["Initialization Scripts"]["STATUS"] = (
"Completed" if getattr(self, "_scripts_success", False) else "Attempted"
)
self._save_prole_cfg()
if self.kerberos_enabled.get():
self.show_page("kerberos_config")
else:
self.show_page("gitops_config")
return
if current_id == "kerberos_config":
# Capture kerberos config
self.prole_cfg_data["Kerberos Authentication"]["ENABLED"] = str(
self.kerberos_enabled.get()
)
self.prole_cfg_data["Kerberos Authentication"][
"REALM"
] = self.kerberos_realm.get()
self.prole_cfg_data["Kerberos Authentication"][
"KDC"
] = self.kerberos_kdc.get()
self.prole_cfg_data["Kerberos Authentication"][
"SERVER"
] = self.kerberos_kdc.get()
self.prole_cfg_data["Kerberos Authentication"][
"USER"
] = self.kerberos_user.get()
self.prole_cfg_data["Kerberos Authentication"][
"PASSWORD"
] = self.kerberos_password.get()
self.prole_cfg_data["Kerberos Authentication"]["AD_PORT_FORWARD"] = (
os.environ.get("KRB5_AD_PORT_FORWARD", "1")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_TCP_PORTS"] = (
os.environ.get("KRB5_AD_TCP_PORTS", "88 389 445 464 636")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_UDP_PORTS"] = (
os.environ.get("KRB5_AD_UDP_PORTS", "88 464")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_HOST_NETWORK"] = (
os.environ.get("KRB5_AD_PROXY_HOST_NETWORK", "1")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_IMAGE"] = (
os.environ.get("KRB5_AD_PROXY_IMAGE", "alpine/socat")
)
self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_SERVICE"] = (
os.environ.get("KRB5_AD_SERVICE_NAME", "prole-kerberos-ad-dc")
)
self._save_prole_cfg()
self.show_page("gitops_config")
return
if current_id == "gitops_config":
self.prole_cfg_data["Optional Features"]["GITOPS_ENABLED"] = str(
self.gitops_enabled.get()
)
gitops_status = (
"Skipped"
if not self.gitops_enabled.get()
else (
"Deployed"
if getattr(self, "_gitops_success", False)
else "Attempted"
)
)
self.prole_cfg_data["GitOps"] = {
"STATUS": gitops_status,
"GITOPS_NAMESPACE": (self.gitops_namespace.get() or "gitea").strip()
or "gitea",
}
self._save_prole_cfg()
self.show_page("supabase_config")
return
if current_id == "supabase_config":
self.prole_cfg_data["Optional Features"]["SUPABASE_ENABLED"] = str(
self.supabase_enabled.get()
)
supabase_status = (
"Skipped"
if not self.supabase_enabled.get()
else (
"Deployed"
if getattr(self, "_supabase_success", False)
else "Attempted"
)
)
self.prole_cfg_data["Supabase"] = {
"STATUS": supabase_status,
}
self._save_prole_cfg()
self.show_page("ollama_config")
return
if current_id == "ollama_config":
self._save_ollama_config()
self.show_page("init_cnpg_deploy")
return
if current_id == "init_cnpg_deploy":
self.prole_cfg_data["Deployment"]["STATUS"] = (
"Deployed" if getattr(self, "_cnpg_success", False) else "Attempted"
)
self._save_prole_cfg()
self.show_page("create_installer")
return
if current_id == "create_installer":
print("[DEBUG] on_next: at create_installer, Finish clicked. Closing.")
if "Install" in self.prole_cfg_data:
self.prole_cfg_data["Install"]["STATUS"] = "Finished"
self._save_prole_cfg()
self.root.destroy()
return
# Default next
if self.page_index < len(self.pages) - 1:
print(f"[DEBUG] on_next: default next to index {self.page_index + 1}")
self.show_page(self.page_index + 1)
else:
print("[DEBUG] on_next: already at last page")
return
def update_footer(self):
# Default states for tk.Button
self.prev_button.configure(state="normal")
self.next_button.configure(state="normal")
first = self.page_index == 0
# Base label
self.next_button.configure(text="Next")
if getattr(self, "_showing_service_overlay", False):
if not getattr(self, "_common_services_success", False):
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
# Page-specific adjustments
pid = self.pages[self.page_index][0]
if pid == "build":
# Build page: show Build or Next depending on state
if getattr(self, "_built_success", False):
self.next_button.configure(text="Next")
else:
self.next_button.configure(text="Build")
elif pid == "supabase_config":
# Supabase screen: allow Next when disabled or after success
if self.supabase_enabled.get():
if getattr(self, "_supabase_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
elif pid == "gitops_config":
if self.gitops_enabled.get():
if getattr(self, "_gitops_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
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 == "kerberos_config":
if self.kerberos_enabled.get():
if getattr(self, "_kerberos_success", False):
self.next_button.configure(state="normal")
else:
self.next_button.configure(state="disabled")
else:
self.next_button.configure(state="normal")
# Visibility rules
self.prev_button.pack_forget()
self.next_button.pack_forget()
if self.deploy_button:
self.deploy_button.pack_forget()
if self.launch_button:
self.launch_button.pack_forget()
if self.gear_button:
self.gear_button.pack_forget()
if pid == "init_password" and self.gear_button:
self.gear_button.pack(side="left", padx=(20, 0), pady=12)
if pid == "create_installer":
self.next_button.configure(text="Finish")
self.next_button.pack(side="right", padx=(0, 20), pady=12)
return
if first:
self.next_button.pack(side="right", padx=(0, 20), pady=12)
else:
# [Prev] [Next] clustered right
self.next_button.pack(side="right", padx=(0, 20), pady=12)
self.prev_button.pack(side="right", padx=(0, 8), pady=12)
def _splash_should_hide_nav(self) -> bool:
"""Deprecated: Welcome page no longer performs dependency checks or gates navigation."""
return False